svc-infra 0.1.589__py3-none-any.whl → 0.1.706__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- 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 +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- 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 +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- 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 +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- 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 +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- 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 +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- 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 -56
- 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/models_schemas/auth/schemas.py.tmpl +1 -1
- 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 +52 -0
- 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 +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- 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.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -129,62 +129,13 @@ for _ix in make_unique_sql_indexes(
|
|
|
129
129
|
# Registered with Table metadata (alembic/autogenerate will pick them up)
|
|
130
130
|
pass
|
|
131
131
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
class
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
- Optionally stores tokens for later API calls (refresh_token encrypted at rest)
|
|
140
|
-
"""
|
|
141
|
-
__tablename__ = "provider_accounts"
|
|
142
|
-
|
|
143
|
-
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
144
|
-
|
|
145
|
-
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
146
|
-
GUID(), ForeignKey("${auth_table_name}.id", ondelete="CASCADE"), nullable=False
|
|
147
|
-
)
|
|
148
|
-
user: Mapped["${AuthEntity}"] = relationship(
|
|
149
|
-
back_populates="provider_accounts",
|
|
150
|
-
lazy="selectin",
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
provider: Mapped[str] = mapped_column(String(50), nullable=False) # "google"|"github"|"linkedin"|"microsoft"|...
|
|
154
|
-
provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False) # sub/oid (OIDC) or id (github/linkedin)
|
|
155
|
-
|
|
156
|
-
# Optional token material
|
|
157
|
-
access_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
158
|
-
|
|
159
|
-
# Store encrypted refresh_token in the same column name for DB compatibility.
|
|
160
|
-
_refresh_token: Mapped[Optional[str]] = mapped_column("refresh_token", Text, nullable=True)
|
|
161
|
-
|
|
162
|
-
@property
|
|
163
|
-
def refresh_token(self) -> Optional[str]:
|
|
164
|
-
return _decrypt(self._refresh_token)
|
|
165
|
-
|
|
166
|
-
@refresh_token.setter
|
|
167
|
-
def refresh_token(self, value: Optional[str]) -> None:
|
|
168
|
-
self._refresh_token = _encrypt(value)
|
|
169
|
-
|
|
170
|
-
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
171
|
-
raw_claims: Mapped[Optional[dict]] = mapped_column(MutableDict.as_mutable(JSON), nullable=True)
|
|
172
|
-
|
|
173
|
-
created_at = mapped_column(
|
|
174
|
-
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
175
|
-
)
|
|
176
|
-
updated_at = mapped_column(
|
|
177
|
-
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"),
|
|
178
|
-
onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
__table_args__ = (
|
|
182
|
-
UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
|
|
183
|
-
Index("ix_provider_accounts_user_id", "user_id"),
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
def __repr__(self) -> str:
|
|
187
|
-
return f"<ProviderAccount provider={self.provider!r} provider_account_id={self.provider_account_id!r} user_id={self.user_id}>"
|
|
132
|
+
# NOTE: ProviderAccount model is imported from svc_infra.security.oauth_models
|
|
133
|
+
# It's an opt-in OAuth model that links users to providers (Google, GitHub, etc.)
|
|
134
|
+
# The relationship 'provider_accounts' is defined above in the ${AuthEntity} class.
|
|
135
|
+
# To enable OAuth in your project:
|
|
136
|
+
# 1. Set ALEMBIC_ENABLE_OAUTH=true in your .env
|
|
137
|
+
# 2. Pass provider_account_model=ProviderAccount to add_auth_users()
|
|
138
|
+
# 3. Import: from svc_infra.security.oauth_models import ProviderAccount
|
|
188
139
|
|
|
189
140
|
# --- Auth service factory ------------------------------------------------------
|
|
190
141
|
|
|
@@ -18,7 +18,7 @@ class Timestamped(BaseModel):
|
|
|
18
18
|
|
|
19
19
|
class ProviderAccountBase(BaseModel):
|
|
20
20
|
model_config = ConfigDict(from_attributes=True)
|
|
21
|
-
provider: str = Field(..., examples
|
|
21
|
+
provider: str = Field(..., json_schema_extra={"examples": ["google", "github", "linkedin", "microsoft"]})
|
|
22
22
|
provider_account_id: str
|
|
23
23
|
|
|
24
24
|
class ProviderAccountRead(ProviderAccountBase, Timestamped):
|
|
@@ -6,13 +6,10 @@ from typing import List, Tuple
|
|
|
6
6
|
import sys, pathlib, importlib, pkgutil, traceback
|
|
7
7
|
|
|
8
8
|
from alembic import context
|
|
9
|
+
from sqlalchemy import MetaData
|
|
9
10
|
from sqlalchemy.engine import make_url, URL
|
|
10
|
-
from sqlalchemy.ext.asyncio import create_async_engine
|
|
11
11
|
|
|
12
|
-
from svc_infra.db.sql.utils import
|
|
13
|
-
get_database_url_from_env,
|
|
14
|
-
_ensure_ssl_default_async as _ensure_ssl_default,
|
|
15
|
-
)
|
|
12
|
+
from svc_infra.db.sql.utils import get_database_url_from_env
|
|
16
13
|
|
|
17
14
|
try:
|
|
18
15
|
from svc_infra.db.sql.types import GUID as _GUID # type: ignore
|
|
@@ -105,7 +102,6 @@ def _coerce_to_async(u: URL) -> URL:
|
|
|
105
102
|
|
|
106
103
|
u = make_url(effective_url)
|
|
107
104
|
u = _coerce_to_async(u)
|
|
108
|
-
u = _ensure_ssl_default(u)
|
|
109
105
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
110
106
|
|
|
111
107
|
# feature flags
|
|
@@ -131,15 +127,16 @@ def _collect_metadata() -> list[object]:
|
|
|
131
127
|
|
|
132
128
|
def _maybe_add(obj: object) -> None:
|
|
133
129
|
md = getattr(obj, "metadata", None) or obj
|
|
134
|
-
|
|
130
|
+
# Strict check: must be actual MetaData instance
|
|
131
|
+
if isinstance(md, MetaData) and md.tables:
|
|
135
132
|
found.append(md)
|
|
136
133
|
|
|
137
134
|
def _scan_module_objects(mod: object) -> None:
|
|
138
135
|
try:
|
|
139
136
|
for val in vars(mod).values():
|
|
140
|
-
|
|
141
|
-
if
|
|
142
|
-
found.append(
|
|
137
|
+
# Strict check: must be actual MetaData instance
|
|
138
|
+
if isinstance(val, MetaData) and val.tables:
|
|
139
|
+
found.append(val)
|
|
143
140
|
except Exception:
|
|
144
141
|
pass
|
|
145
142
|
|
|
@@ -177,8 +174,16 @@ def _collect_metadata() -> list[object]:
|
|
|
177
174
|
if name not in pkgs:
|
|
178
175
|
pkgs.append(name)
|
|
179
176
|
|
|
177
|
+
# Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
|
|
180
178
|
if "models" not in pkgs:
|
|
181
|
-
|
|
179
|
+
try:
|
|
180
|
+
spec = getattr(importlib, "util", None)
|
|
181
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
182
|
+
if spec.find_spec("models") is not None:
|
|
183
|
+
pkgs.append("models")
|
|
184
|
+
except Exception:
|
|
185
|
+
# Best-effort; if discovery fails, skip adding bare 'models'
|
|
186
|
+
pass
|
|
182
187
|
|
|
183
188
|
def _import_and_collect(modname: str):
|
|
184
189
|
try:
|
|
@@ -221,6 +226,21 @@ def _collect_metadata() -> list[object]:
|
|
|
221
226
|
except Exception:
|
|
222
227
|
_note("ModelBase import", False, traceback.format_exc())
|
|
223
228
|
|
|
229
|
+
# Core security models (AuthSession, RefreshToken, etc.)
|
|
230
|
+
try:
|
|
231
|
+
import svc_infra.security.models # noqa: F401
|
|
232
|
+
_note("svc_infra.security.models", True, None)
|
|
233
|
+
except Exception:
|
|
234
|
+
_note("svc_infra.security.models", False, traceback.format_exc())
|
|
235
|
+
|
|
236
|
+
# OAuth models (opt-in via environment variable)
|
|
237
|
+
if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
|
|
238
|
+
try:
|
|
239
|
+
import svc_infra.security.oauth_models # noqa: F401
|
|
240
|
+
_note("svc_infra.security.oauth_models", True, None)
|
|
241
|
+
except Exception:
|
|
242
|
+
_note("svc_infra.security.oauth_models", False, traceback.format_exc())
|
|
243
|
+
|
|
224
244
|
try:
|
|
225
245
|
from svc_infra.db.sql.apikey import try_autobind_apikey_model
|
|
226
246
|
try_autobind_apikey_model(require_env=False)
|
|
@@ -352,7 +372,9 @@ def _do_run_migrations(connection):
|
|
|
352
372
|
|
|
353
373
|
async def run_migrations_online() -> None:
|
|
354
374
|
url = config.get_main_option("sqlalchemy.url")
|
|
355
|
-
|
|
375
|
+
# Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
|
|
376
|
+
from svc_infra.db.sql.utils import build_engine
|
|
377
|
+
engine = build_engine(url)
|
|
356
378
|
async with engine.connect() as connection:
|
|
357
379
|
await connection.run_sync(_do_run_migrations)
|
|
358
380
|
await engine.dispose()
|
|
@@ -6,11 +6,10 @@ from typing import List, Tuple
|
|
|
6
6
|
import sys, pathlib, importlib, pkgutil, traceback
|
|
7
7
|
|
|
8
8
|
from alembic import context
|
|
9
|
+
from sqlalchemy import MetaData
|
|
9
10
|
from sqlalchemy.engine import make_url, URL
|
|
10
11
|
|
|
11
12
|
from svc_infra.db.sql.utils import (
|
|
12
|
-
_coerce_sync_driver,
|
|
13
|
-
_ensure_ssl_default,
|
|
14
13
|
get_database_url_from_env,
|
|
15
14
|
build_engine,
|
|
16
15
|
)
|
|
@@ -103,7 +102,6 @@ if not effective_url:
|
|
|
103
102
|
|
|
104
103
|
u = make_url(effective_url)
|
|
105
104
|
u = _coerce_sync_driver(u)
|
|
106
|
-
u = _ensure_ssl_default(u)
|
|
107
105
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
108
106
|
|
|
109
107
|
|
|
@@ -142,14 +140,16 @@ def _collect_metadata() -> list[object]:
|
|
|
142
140
|
|
|
143
141
|
def _maybe_add(obj: object) -> None:
|
|
144
142
|
md = getattr(obj, "metadata", None) or obj
|
|
145
|
-
|
|
143
|
+
# Strict check: must be actual MetaData instance
|
|
144
|
+
if isinstance(md, MetaData) and md.tables:
|
|
146
145
|
found.append(md)
|
|
147
146
|
|
|
148
147
|
def _scan_module_objects(mod: object) -> None:
|
|
149
148
|
try:
|
|
150
149
|
for val in vars(mod).values():
|
|
151
150
|
md = getattr(val, "metadata", None) or None
|
|
152
|
-
if
|
|
151
|
+
# Only add if it's a SQLAlchemy MetaData object (has tables dict, not a callable/generator)
|
|
152
|
+
if md is not None and hasattr(md, "tables") and isinstance(getattr(md, "tables", None), dict):
|
|
153
153
|
found.append(md)
|
|
154
154
|
except Exception:
|
|
155
155
|
pass
|
|
@@ -191,9 +191,16 @@ def _collect_metadata() -> list[object]:
|
|
|
191
191
|
if name not in pkgs:
|
|
192
192
|
pkgs.append(name)
|
|
193
193
|
|
|
194
|
-
#
|
|
194
|
+
# Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
|
|
195
195
|
if "models" not in pkgs:
|
|
196
|
-
|
|
196
|
+
try:
|
|
197
|
+
spec = getattr(importlib, "util", None)
|
|
198
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
199
|
+
if spec.find_spec("models") is not None:
|
|
200
|
+
pkgs.append("models")
|
|
201
|
+
except Exception:
|
|
202
|
+
# If discovery fails, skip adding bare 'models'
|
|
203
|
+
pass
|
|
197
204
|
|
|
198
205
|
def _import_and_collect(modname: str):
|
|
199
206
|
try:
|
|
@@ -239,6 +246,21 @@ def _collect_metadata() -> list[object]:
|
|
|
239
246
|
except Exception:
|
|
240
247
|
_note("ModelBase import", False, traceback.format_exc())
|
|
241
248
|
|
|
249
|
+
# Core security models (AuthSession, RefreshToken, etc.)
|
|
250
|
+
try:
|
|
251
|
+
import svc_infra.security.models # noqa: F401
|
|
252
|
+
_note("svc_infra.security.models", True, None)
|
|
253
|
+
except Exception:
|
|
254
|
+
_note("svc_infra.security.models", False, traceback.format_exc())
|
|
255
|
+
|
|
256
|
+
# OAuth models (opt-in via environment variable)
|
|
257
|
+
if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
|
|
258
|
+
try:
|
|
259
|
+
import svc_infra.security.oauth_models # noqa: F401
|
|
260
|
+
_note("svc_infra.security.oauth_models", True, None)
|
|
261
|
+
except Exception:
|
|
262
|
+
_note("svc_infra.security.oauth_models", False, traceback.format_exc())
|
|
263
|
+
|
|
242
264
|
# Optional: autobind API key model
|
|
243
265
|
try:
|
|
244
266
|
from svc_infra.db.sql.apikey import try_autobind_apikey_model
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Sequence
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from .service import SqlService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenantSqlService(SqlService):
|
|
11
|
+
"""
|
|
12
|
+
SQL service wrapper that automatically scopes operations to a tenant.
|
|
13
|
+
|
|
14
|
+
- Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
|
|
15
|
+
- On create, if the model has the tenant field and it's not set in data, injects tenant_id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
|
|
19
|
+
super().__init__(repo)
|
|
20
|
+
self.tenant_id = tenant_id
|
|
21
|
+
self.tenant_field = tenant_field
|
|
22
|
+
|
|
23
|
+
def _where(self) -> Sequence[Any]:
|
|
24
|
+
model = self.repo.model
|
|
25
|
+
col = getattr(model, self.tenant_field, None)
|
|
26
|
+
if col is None:
|
|
27
|
+
return []
|
|
28
|
+
return [col == self.tenant_id]
|
|
29
|
+
|
|
30
|
+
async def list(
|
|
31
|
+
self, session: AsyncSession, *, limit: int, offset: int, order_by=None
|
|
32
|
+
):
|
|
33
|
+
return await self.repo.list(
|
|
34
|
+
session, limit=limit, offset=offset, order_by=order_by, where=self._where()
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def count(self, session: AsyncSession) -> int:
|
|
38
|
+
return await self.repo.count(session, where=self._where())
|
|
39
|
+
|
|
40
|
+
async def get(self, session: AsyncSession, id_value: Any):
|
|
41
|
+
return await self.repo.get(session, id_value, where=self._where())
|
|
42
|
+
|
|
43
|
+
async def create(self, session: AsyncSession, data: dict[str, Any]):
|
|
44
|
+
data = await self.pre_create(data)
|
|
45
|
+
# inject tenant_id if model supports it and value missing
|
|
46
|
+
if (
|
|
47
|
+
self.tenant_field in self.repo._model_columns()
|
|
48
|
+
and self.tenant_field not in data
|
|
49
|
+
):
|
|
50
|
+
data[self.tenant_field] = self.tenant_id
|
|
51
|
+
return await self.repo.create(session, data)
|
|
52
|
+
|
|
53
|
+
async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
|
|
54
|
+
data = await self.pre_update(data)
|
|
55
|
+
return await self.repo.update(session, id_value, data, where=self._where())
|
|
56
|
+
|
|
57
|
+
async def delete(self, session: AsyncSession, id_value: Any) -> bool:
|
|
58
|
+
return await self.repo.delete(session, id_value, where=self._where())
|
|
59
|
+
|
|
60
|
+
async def search(
|
|
61
|
+
self,
|
|
62
|
+
session: AsyncSession,
|
|
63
|
+
*,
|
|
64
|
+
q: str,
|
|
65
|
+
fields: Sequence[str],
|
|
66
|
+
limit: int,
|
|
67
|
+
offset: int,
|
|
68
|
+
order_by=None,
|
|
69
|
+
):
|
|
70
|
+
return await self.repo.search(
|
|
71
|
+
session,
|
|
72
|
+
q=q,
|
|
73
|
+
fields=fields,
|
|
74
|
+
limit=limit,
|
|
75
|
+
offset=offset,
|
|
76
|
+
order_by=order_by,
|
|
77
|
+
where=self._where(),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def count_filtered(
|
|
81
|
+
self, session: AsyncSession, *, q: str, fields: Sequence[str]
|
|
82
|
+
) -> int:
|
|
83
|
+
return await self.repo.count_filtered(
|
|
84
|
+
session, q=q, fields=fields, where=self._where()
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["TenantSqlService"]
|
svc_infra/db/sql/uniq_hooks.py
CHANGED
|
@@ -73,7 +73,9 @@ def dedupe_sql_service(
|
|
|
73
73
|
|
|
74
74
|
return clauses
|
|
75
75
|
|
|
76
|
-
async def _precheck(
|
|
76
|
+
async def _precheck(
|
|
77
|
+
session, data: Dict[str, Any], *, exclude_id: Any | None
|
|
78
|
+
) -> None:
|
|
77
79
|
# Check CI specs first to catch the broadest conflicts, then CS.
|
|
78
80
|
for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
|
|
79
81
|
for spec in spec_list:
|
|
@@ -97,7 +99,9 @@ def dedupe_sql_service(
|
|
|
97
99
|
return await self.repo.create(session, data)
|
|
98
100
|
except IntegrityError as e:
|
|
99
101
|
# Race fallback: let DB constraint be the last line of defense.
|
|
100
|
-
raise HTTPException(
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=409, detail="Record already exists."
|
|
104
|
+
) from e
|
|
101
105
|
|
|
102
106
|
async def update(self, session, id_value, data):
|
|
103
107
|
data = await self.pre_update(data)
|
|
@@ -105,6 +109,8 @@ def dedupe_sql_service(
|
|
|
105
109
|
try:
|
|
106
110
|
return await self.repo.update(session, id_value, data)
|
|
107
111
|
except IntegrityError as e:
|
|
108
|
-
raise HTTPException(
|
|
112
|
+
raise HTTPException(
|
|
113
|
+
status_code=409, detail="Record already exists."
|
|
114
|
+
) from e
|
|
109
115
|
|
|
110
116
|
return _Svc(repo, pre_create=pre_create, pre_update=pre_update)
|