svc-infra 0.1.640__py3-none-any.whl → 0.1.664__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (33) hide show
  1. svc_infra/api/fastapi/apf_payments/setup.py +0 -2
  2. svc_infra/api/fastapi/auth/add.py +0 -4
  3. svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
  4. svc_infra/api/fastapi/cache/add.py +9 -5
  5. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  6. svc_infra/api/fastapi/db/sql/add.py +8 -5
  7. svc_infra/api/fastapi/db/sql/crud_router.py +4 -4
  8. svc_infra/api/fastapi/docs/scoped.py +41 -6
  9. svc_infra/api/fastapi/setup.py +10 -12
  10. svc_infra/api/fastapi/versioned.py +101 -0
  11. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  12. svc_infra/db/sql/templates/setup/env_async.py.tmpl +25 -11
  13. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +20 -5
  14. svc_infra/docs/acceptance-matrix.md +17 -0
  15. svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
  16. svc_infra/docs/api.md +127 -0
  17. svc_infra/docs/storage.md +982 -0
  18. svc_infra/docs/versioned-integrations.md +146 -0
  19. svc_infra/security/models.py +27 -7
  20. svc_infra/security/oauth_models.py +59 -0
  21. svc_infra/storage/__init__.py +93 -0
  22. svc_infra/storage/add.py +250 -0
  23. svc_infra/storage/backends/__init__.py +11 -0
  24. svc_infra/storage/backends/local.py +331 -0
  25. svc_infra/storage/backends/memory.py +214 -0
  26. svc_infra/storage/backends/s3.py +329 -0
  27. svc_infra/storage/base.py +239 -0
  28. svc_infra/storage/easy.py +182 -0
  29. svc_infra/storage/settings.py +192 -0
  30. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/METADATA +8 -3
  31. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/RECORD +33 -19
  32. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
  33. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
@@ -7,7 +7,6 @@ from fastapi import FastAPI
7
7
 
8
8
  from svc_infra.apf_payments.provider.registry import get_provider_registry
9
9
  from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
10
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
11
10
 
12
11
  logger = logging.getLogger(__name__)
13
12
 
@@ -51,7 +50,6 @@ def add_payments(
51
50
  - Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
52
51
  """
53
52
  _maybe_register_default_providers(register_default_providers, adapters)
54
- add_prefixed_docs(app, prefix=prefix, title="Payments")
55
53
 
56
54
  for r in build_payments_routers(prefix=prefix):
57
55
  app.include_router(
@@ -17,7 +17,6 @@ from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
17
17
  from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
18
18
  from svc_infra.db.sql.apikey import bind_apikey_model
19
19
 
20
- from ..docs.scoped import add_prefixed_docs
21
20
  from .policy import AuthPolicy, DefaultAuthPolicy
22
21
  from .providers import providers_from_settings
23
22
  from .settings import get_auth_settings
@@ -293,9 +292,6 @@ def add_auth_users(
293
292
  https_only=bool(getattr(settings_obj, "session_cookie_secure", False)),
294
293
  )
295
294
 
296
- add_prefixed_docs(app, prefix=user_prefix, title="Users")
297
- add_prefixed_docs(app, prefix=auth_prefix, title="Auth")
298
-
299
295
  if enable_password:
300
296
  setup_password_authentication(
301
297
  app,
@@ -373,9 +373,8 @@ async def _update_provider_account(
373
373
  def _determine_final_redirect_url(request: Request, provider: str, post_login_redirect: str) -> str:
374
374
  """Determine the final redirect URL after successful authentication."""
375
375
  st = get_auth_settings()
376
- redirect_url = str(
377
- getattr(st, "post_login_redirect", post_login_redirect) or post_login_redirect
378
- )
376
+ # Prioritize the parameter passed to the router over settings
377
+ redirect_url = str(post_login_redirect or getattr(st, "post_login_redirect", "/"))
379
378
  allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
380
379
  require_https = bool(getattr(st, "session_cookie_secure", False))
381
380
 
@@ -669,7 +668,23 @@ def _create_oauth_router(
669
668
  ip_hash=None,
670
669
  )
671
670
 
672
- # Create response with auth + refresh cookies
671
+ # Generate JWT token for the response
672
+ strategy = auth_backend.get_strategy()
673
+ jwt_token = await strategy.write_token(user)
674
+
675
+ # If redirecting to a different origin, append token as URL fragment for frontend to extract
676
+ # This handles cross-port scenarios like localhost:8000 -> localhost:3000
677
+ parsed_redirect = urlparse(redirect_url)
678
+ request_origin = f"{request.url.scheme}://{request.url.netloc}"
679
+ redirect_origin = f"{parsed_redirect.scheme}://{parsed_redirect.netloc}"
680
+
681
+ if redirect_origin and redirect_origin != request_origin:
682
+ # Cross-origin redirect: append token as URL fragment
683
+ # Fragment is not sent to server, only accessible to client-side JS
684
+ separator = "#" if not parsed_redirect.fragment else "&"
685
+ redirect_url = f"{redirect_url}{separator}access_token={jwt_token}"
686
+
687
+ # Create response with auth + refresh cookies (for same-origin requests)
673
688
  resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
674
689
  await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
675
690
 
@@ -1,3 +1,5 @@
1
+ from contextlib import asynccontextmanager
2
+
1
3
  from fastapi import FastAPI
2
4
 
3
5
  from svc_infra.cache.backend import shutdown_cache
@@ -5,10 +7,12 @@ from svc_infra.cache.decorators import init_cache
5
7
 
6
8
 
7
9
  def setup_caching(app: FastAPI) -> None:
8
- @app.on_event("startup")
9
- async def _startup():
10
+ @asynccontextmanager
11
+ async def lifespan(_app: FastAPI):
10
12
  init_cache()
13
+ try:
14
+ yield
15
+ finally:
16
+ await shutdown_cache()
11
17
 
12
- @app.on_event("shutdown")
13
- async def _shutdown():
14
- await shutdown_cache()
18
+ app.router.lifespan_context = lifespan
@@ -38,8 +38,8 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
38
38
 
39
39
 
40
40
  def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
41
- @app.on_event("startup")
42
- async def _startup() -> None:
41
+ @asynccontextmanager
42
+ async def lifespan(_app: FastAPI):
43
43
  if not os.getenv(dsn_env):
44
44
  raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
45
45
  await init_mongo()
@@ -47,10 +47,12 @@ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
47
47
  db = await acquire_db()
48
48
  if expected and db.name != expected:
49
49
  raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
50
+ try:
51
+ yield
52
+ finally:
53
+ await close_mongo()
50
54
 
51
- @app.on_event("shutdown")
52
- async def _shutdown() -> None:
53
- await close_mongo()
55
+ app.router.lifespan_context = lifespan
54
56
 
55
57
 
56
58
  def add_mongo_health(
@@ -62,46 +64,50 @@ def add_mongo_health(
62
64
 
63
65
 
64
66
  def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
65
- for r in resources:
67
+ for resource in resources:
66
68
  repo = NoSqlRepository(
67
- collection_name=r.resolved_collection(),
68
- id_field=r.id_field,
69
- soft_delete=r.soft_delete,
70
- soft_delete_field=r.soft_delete_field,
71
- soft_delete_flag_field=r.soft_delete_flag_field,
69
+ collection_name=resource.resolved_collection(),
70
+ id_field=resource.id_field,
71
+ soft_delete=resource.soft_delete,
72
+ soft_delete_field=resource.soft_delete_field,
73
+ soft_delete_flag_field=resource.soft_delete_flag_field,
72
74
  )
73
- svc = r.service_factory(repo) if r.service_factory else NoSqlService(repo)
75
+ svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
74
76
 
75
- if r.read_schema and r.create_schema and r.update_schema:
76
- Read, Create, Update = r.read_schema, r.create_schema, r.update_schema
77
- elif r.document_model is not None:
77
+ if resource.read_schema and resource.create_schema and resource.update_schema:
78
+ Read, Create, Update = (
79
+ resource.read_schema,
80
+ resource.create_schema,
81
+ resource.update_schema,
82
+ )
83
+ elif resource.document_model is not None:
78
84
  # CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
79
85
  Read, Create, Update = make_document_crud_schemas(
80
- r.document_model,
81
- create_exclude=r.create_exclude,
82
- read_name=r.read_name,
83
- create_name=r.create_name,
84
- update_name=r.update_name,
85
- read_exclude=r.read_exclude,
86
- update_exclude=r.update_exclude,
86
+ resource.document_model,
87
+ create_exclude=resource.create_exclude,
88
+ read_name=resource.read_name,
89
+ create_name=resource.create_name,
90
+ update_name=resource.update_name,
91
+ read_exclude=resource.read_exclude,
92
+ update_exclude=resource.update_exclude,
87
93
  json_encoders={ObjectId: str, PyObjectId: str},
88
94
  )
89
95
  else:
90
96
  raise RuntimeError(
91
- f"Resource for collection '{r.collection}' requires either explicit schemas "
97
+ f"Resource for collection '{resource.collection}' requires either explicit schemas "
92
98
  f"(read/create/update) or a 'document_model' to derive them."
93
99
  )
94
100
 
95
101
  router = make_crud_router_plus_mongo(
96
- collection=r.resolved_collection(),
102
+ collection=resource.resolved_collection(),
97
103
  repo=repo,
98
104
  service=svc,
99
105
  read_schema=Read,
100
106
  create_schema=Create,
101
107
  update_schema=Update,
102
- prefix=r.prefix,
103
- tags=r.tags,
104
- search_fields=r.search_fields,
108
+ prefix=resource.prefix,
109
+ tags=resource.tags,
110
+ search_fields=resource.search_fields,
105
111
  default_ordering=None,
106
112
  allowed_order_fields=None,
107
113
  )
@@ -86,16 +86,19 @@ def add_sql_db(app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_U
86
86
  app.router.lifespan_context = lifespan
87
87
  return
88
88
 
89
- @app.on_event("startup")
90
- async def _startup() -> None: # noqa: ANN202
89
+ # Use lifespan context manager instead of deprecated on_event
90
+ @asynccontextmanager
91
+ async def lifespan(_app: FastAPI):
91
92
  env_url = os.getenv(dsn_env)
92
93
  if not env_url:
93
94
  raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
94
95
  initialize_session(env_url)
96
+ try:
97
+ yield
98
+ finally:
99
+ await dispose_session()
95
100
 
96
- @app.on_event("shutdown")
97
- async def _shutdown() -> None: # noqa: ANN202
98
- await dispose_session()
101
+ app.router.lifespan_context = lifespan
99
102
 
100
103
 
101
104
  def add_sql_health(
@@ -122,7 +122,7 @@ def make_crud_router_plus_sql(
122
122
  )
123
123
  async def create_item(
124
124
  session: SqlSessionDep, # type: ignore[name-defined]
125
- payload: Any = Body(...),
125
+ payload: create_schema = Body(...),
126
126
  ):
127
127
  if isinstance(payload, BaseModel):
128
128
  data = payload.model_dump(exclude_unset=True)
@@ -141,7 +141,7 @@ def make_crud_router_plus_sql(
141
141
  async def update_item(
142
142
  item_id: Any,
143
143
  session: SqlSessionDep, # type: ignore[name-defined]
144
- payload: Any = Body(...),
144
+ payload: update_schema = Body(...),
145
145
  ):
146
146
  if isinstance(payload, BaseModel):
147
147
  data = payload.model_dump(exclude_unset=True)
@@ -266,7 +266,7 @@ def make_tenant_crud_router_plus_sql(
266
266
  async def create_item(
267
267
  session: SqlSessionDep, # type: ignore[name-defined]
268
268
  tenant_id: TenantId,
269
- payload: Any = Body(...),
269
+ payload: create_schema = Body(...),
270
270
  ):
271
271
  svc = await _svc(session, tenant_id)
272
272
  if isinstance(payload, BaseModel):
@@ -282,7 +282,7 @@ def make_tenant_crud_router_plus_sql(
282
282
  item_id: Any,
283
283
  session: SqlSessionDep, # type: ignore[name-defined]
284
284
  tenant_id: TenantId,
285
- payload: Any = Body(...),
285
+ payload: update_schema = Body(...),
286
286
  ):
287
287
  svc = await _svc(session, tenant_id)
288
288
  if isinstance(payload, BaseModel):
@@ -65,11 +65,18 @@ def _close_over_component_refs(
65
65
 
66
66
 
67
67
  def _prune_to_paths(
68
- full_schema: Dict, keep_paths: Dict[str, dict], title_suffix: Optional[str]
68
+ full_schema: Dict,
69
+ keep_paths: Dict[str, dict],
70
+ title_suffix: Optional[str],
71
+ server_prefix: Optional[str] = None,
69
72
  ) -> Dict:
70
73
  schema = copy.deepcopy(full_schema)
71
74
  schema["paths"] = keep_paths
72
75
 
76
+ # Set server URL for scoped docs
77
+ if server_prefix is not None:
78
+ schema["servers"] = [{"url": server_prefix}]
79
+
73
80
  used_tags: Set[str] = set()
74
81
  direct_refs: Set[Tuple[str, str]] = set()
75
82
  used_security_schemes: Set[str] = set()
@@ -124,7 +131,26 @@ def _build_filtered_schema(
124
131
  keep_paths = {
125
132
  p: v for p, v in paths.items() if _path_included(p, include_prefixes, exclude_prefixes)
126
133
  }
127
- return _prune_to_paths(full_schema, keep_paths, title_suffix)
134
+
135
+ # Determine the server prefix for scoped docs
136
+ server_prefix = None
137
+ if include_prefixes and len(include_prefixes) == 1:
138
+ # Single include prefix = scoped docs
139
+ server_prefix = include_prefixes[0].rstrip("/") or "/"
140
+
141
+ # Strip prefix from paths to make them relative to the server
142
+ stripped_paths = {}
143
+ for path, spec in keep_paths.items():
144
+ if path.startswith(server_prefix) and path != server_prefix:
145
+ # Remove prefix, keeping the leading slash
146
+ relative_path = path[len(server_prefix) :]
147
+ stripped_paths[relative_path] = spec
148
+ else:
149
+ # Path equals prefix or doesn't start with it
150
+ stripped_paths[path] = spec
151
+ keep_paths = stripped_paths
152
+
153
+ return _prune_to_paths(full_schema, keep_paths, title_suffix, server_prefix=server_prefix)
128
154
 
129
155
 
130
156
  def _ensure_original_openapi_saved(app: FastAPI) -> None:
@@ -175,11 +201,23 @@ def add_prefixed_docs(
175
201
  auto_exclude_from_root: bool = True,
176
202
  visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
177
203
  ) -> None:
204
+ scope = prefix.rstrip("/") or "/"
205
+
206
+ # Always exclude from root if requested, regardless of environment
207
+ if auto_exclude_from_root:
208
+ _ensure_original_openapi_saved(app)
209
+ # Add to exclusion list for root docs
210
+ if not hasattr(app.state, "_scoped_root_exclusions"):
211
+ app.state._scoped_root_exclusions = []
212
+ if scope not in app.state._scoped_root_exclusions:
213
+ app.state._scoped_root_exclusions.append(scope)
214
+ _install_root_filter(app, app.state._scoped_root_exclusions)
215
+
216
+ # Only create scoped docs in allowed environments
178
217
  allow = _normalize_envs(visible_envs)
179
218
  if allow is not None and CURRENT_ENVIRONMENT not in allow:
180
219
  return
181
220
 
182
- scope = prefix.rstrip("/") or "/"
183
221
  openapi_path = f"{scope}/openapi.json"
184
222
  swagger_path = f"{scope}/docs"
185
223
  redoc_path = f"{scope}/redoc"
@@ -211,9 +249,6 @@ def add_prefixed_docs(
211
249
 
212
250
  DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
213
251
 
214
- if auto_exclude_from_root:
215
- _ensure_root_excludes_registered_scopes(app)
216
-
217
252
 
218
253
  def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: List[str]) -> None:
219
254
  _install_root_filter(app, exclude_prefixes)
@@ -158,8 +158,7 @@ def _build_parent_app(
158
158
  root_server_url: str | None = None,
159
159
  root_include_api_key: bool = False,
160
160
  ) -> FastAPI:
161
- show_root_docs = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
162
-
161
+ # Root docs are now enabled in all environments to match root card visibility
163
162
  parent = FastAPI(
164
163
  title=service.name,
165
164
  version=service.release,
@@ -167,9 +166,9 @@ def _build_parent_app(
167
166
  license_info=_dump_or_none(service.license),
168
167
  terms_of_service=service.terms_of_service,
169
168
  description=service.description,
170
- docs_url=("/docs" if show_root_docs else None),
171
- redoc_url=("/redoc" if show_root_docs else None),
172
- openapi_url=("/openapi.json" if show_root_docs else None),
169
+ docs_url="/docs",
170
+ redoc_url="/redoc",
171
+ openapi_url="/openapi.json",
173
172
  )
174
173
 
175
174
  _setup_cors(parent, public_cors_origins)
@@ -247,14 +246,13 @@ def setup_service_api(
247
246
  cards: list[CardSpec] = []
248
247
  is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
249
248
 
250
- if is_local_dev:
251
- # Root card
252
- cards.append(
253
- CardSpec(
254
- tag="",
255
- docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
256
- )
249
+ # Root card - always show in all environments
250
+ cards.append(
251
+ CardSpec(
252
+ tag="",
253
+ docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
257
254
  )
255
+ )
258
256
 
259
257
  # Version cards
260
258
  for spec in versions:
@@ -0,0 +1,101 @@
1
+ """
2
+ Utilities for capturing routers from add_* functions for versioned routing.
3
+
4
+ This module provides helpers to use integration functions (add_banking, add_payments, etc.)
5
+ under versioned routing without creating separate documentation cards.
6
+
7
+ See: svc-infra/docs/versioned-integrations.md
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, TypeVar
13
+ from unittest.mock import patch
14
+
15
+ from fastapi import APIRouter, FastAPI
16
+
17
+ __all__ = ["extract_router"]
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ def extract_router(
23
+ add_function: Callable[..., T],
24
+ *,
25
+ prefix: str,
26
+ **kwargs: Any,
27
+ ) -> tuple[APIRouter, T]:
28
+ """
29
+ Capture the router from an add_* function for versioned mounting.
30
+
31
+ This allows you to use integration functions like add_banking(), add_payments(),
32
+ etc. under versioned routing (e.g., /v0/banking) without creating separate
33
+ documentation cards.
34
+
35
+ Args:
36
+ add_function: The add_* function to capture from (e.g., add_banking)
37
+ prefix: URL prefix for the routes (e.g., "/banking")
38
+ **kwargs: Arguments to pass to the add_function
39
+
40
+ Returns:
41
+ Tuple of (router, return_value) where:
42
+ - router: The captured APIRouter with all routes
43
+ - return_value: The original return value from add_function (e.g., provider instance)
44
+
45
+ Example:
46
+ ```python
47
+ # In routers/v0/banking.py
48
+ from svc_infra.api.fastapi.versioned import extract_router
49
+ from fin_infra.banking import add_banking
50
+
51
+ router, banking_provider = extract_router(
52
+ add_banking,
53
+ prefix="/banking",
54
+ provider="plaid",
55
+ cache_ttl=60,
56
+ )
57
+
58
+ # svc-infra auto-discovers 'router' and mounts at /v0/banking
59
+ ```
60
+
61
+ Pattern:
62
+ 1. Creates a mock FastAPI app
63
+ 2. Intercepts include_router to capture the router
64
+ 3. Patches add_prefixed_docs to prevent separate card creation
65
+ 4. Calls the add_function which creates all routes
66
+ 5. Returns the captured router for auto-discovery
67
+
68
+ See Also:
69
+ - docs/versioned-integrations.md: Full pattern documentation
70
+ - api/fastapi/dual/public.py: Similar pattern for dual routers
71
+ """
72
+ # Create mock app to capture router
73
+ mock_app = FastAPI()
74
+ captured_router: APIRouter | None = None
75
+
76
+ def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
77
+ """Intercept include_router to capture instead of mount."""
78
+ nonlocal captured_router
79
+ captured_router = router
80
+
81
+ mock_app.include_router = _capture_router # type: ignore[assignment]
82
+
83
+ # Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
84
+ def _noop_docs(*args: Any, **kwargs: Any) -> None:
85
+ pass
86
+
87
+ # Call add_function with patches active
88
+ with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
89
+ result = add_function(
90
+ mock_app,
91
+ prefix=prefix,
92
+ **kwargs,
93
+ )
94
+
95
+ if captured_router is None:
96
+ raise RuntimeError(
97
+ f"Failed to capture router from {add_function.__name__}. "
98
+ f"The function may not call app.include_router()."
99
+ )
100
+
101
+ return captured_router, result
@@ -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
- # ------------------------------ Model: ProviderAccount -------------------------
133
-
134
- class ProviderAccount(ModelBase):
135
- """
136
- Links a local user to an external identity provider account.
137
-
138
- - (provider, provider_account_id) is unique
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
 
@@ -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
- if hasattr(md, "tables") and getattr(md, "tables"):
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
- md = getattr(val, "metadata", None) or None
141
- if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
142
- found.append(md)
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
 
@@ -229,6 +226,21 @@ def _collect_metadata() -> list[object]:
229
226
  except Exception:
230
227
  _note("ModelBase import", False, traceback.format_exc())
231
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
+
232
244
  try:
233
245
  from svc_infra.db.sql.apikey import try_autobind_apikey_model
234
246
  try_autobind_apikey_model(require_env=False)
@@ -360,7 +372,9 @@ def _do_run_migrations(connection):
360
372
 
361
373
  async def run_migrations_online() -> None:
362
374
  url = config.get_main_option("sqlalchemy.url")
363
- engine = create_async_engine(url)
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)
364
378
  async with engine.connect() as connection:
365
379
  await connection.run_sync(_do_run_migrations)
366
380
  await engine.dispose()