Spaces:
Running
Running
File size: 5,614 Bytes
4f09096 325ae04 fda44a3 4f09096 547727a fda44a3 4f09096 325ae04 fda44a3 325ae04 fda44a3 325ae04 48aa04d fda44a3 325ae04 48aa04d fda44a3 325ae04 4f09096 325ae04 fda44a3 48aa04d fda44a3 325ae04 4f09096 fda44a3 4f09096 547727a fda44a3 547727a fda44a3 547727a fda44a3 547727a fda44a3 547727a fda44a3 547727a fda44a3 547727a fda44a3 547727a fda44a3 547727a 4f09096 547727a 4f09096 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from uuid import uuid4
from sqlalchemy import event, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from app.security.models import Base
def _async_database_url(database_url: str) -> str:
url = database_url.strip()
for prefix in ("postgres://", "postgresql://", "postgresql+psycopg2://", "postgresql+psycopg://"):
if url.startswith(prefix):
url = "postgresql+asyncpg://" + url[len(prefix):]
break
parts = urlsplit(url)
if parts.scheme != "postgresql+asyncpg":
return url
query = parse_qsl(parts.query, keep_blank_values=True)
normalized = []
has_cache_setting = False
for key, value in query:
if key == "sslmode":
if value in {"require", "verify-ca", "verify-full"}:
normalized.append(("ssl", value))
continue
if key == "prepared_statement_cache_size":
has_cache_setting = True
normalized.append((key, value))
if not has_cache_setting:
normalized.append(("prepared_statement_cache_size", "0"))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(normalized), parts.fragment))
class SecurityDatabase:
def __init__(self, database_url: str) -> None:
async_url = _async_database_url(database_url)
options: dict[str, object] = {"pool_pre_ping": True}
if async_url.startswith("postgresql+asyncpg://"):
# Unique names avoid PgBouncer transaction-pool collisions even
# when the client-side prepared-statement cache is disabled.
options["connect_args"] = {
"statement_cache_size": 0,
"prepared_statement_name_func": lambda: "__asyncpg_" + uuid4().hex + "__",
}
self.engine: AsyncEngine = create_async_engine(async_url, **options)
if async_url.startswith("sqlite"):
event.listen(self.engine.sync_engine, "connect", self._configure_sqlite)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
@staticmethod
def _configure_sqlite(dbapi_connection: object, _record: object) -> None:
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
@staticmethod
async def _migrate_postgres_uuid_columns(connection: object) -> None:
conn = connection
result = await conn.execute(text("""
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND (table_name, column_name) IN (
('api_keys', 'id'), ('api_keys', 'rotated_from_id'),
('audit_logs', 'api_key_id'), ('rate_limits', 'api_key_id')
)
"""))
types = {(r.table_name, r.column_name): r.data_type for r in result.fetchall()}
if not types:
return
await conn.execute(text("ALTER TABLE IF EXISTS api_keys DROP CONSTRAINT IF EXISTS api_keys_rotated_from_id_fkey"))
await conn.execute(text("ALTER TABLE IF EXISTS audit_logs DROP CONSTRAINT IF EXISTS audit_logs_api_key_id_fkey"))
await conn.execute(text("ALTER TABLE IF EXISTS rate_limits DROP CONSTRAINT IF EXISTS rate_limits_api_key_id_fkey"))
for table, column in (
("api_keys", "id"), ("api_keys", "rotated_from_id"),
("audit_logs", "api_key_id"), ("rate_limits", "api_key_id"),
):
if types.get((table, column)) in {"character varying", "text", "varchar"}:
await conn.execute(text(
f'ALTER TABLE "{table}" ALTER COLUMN "{column}" TYPE UUID '
f"""USING NULLIF("{column}"::text, '')::uuid"""
))
if ("api_keys", "id") in types:
if ("api_keys", "rotated_from_id") in types:
await conn.execute(text("""
ALTER TABLE api_keys ADD CONSTRAINT api_keys_rotated_from_id_fkey
FOREIGN KEY (rotated_from_id) REFERENCES api_keys(id) ON DELETE SET NULL
"""))
if ("audit_logs", "api_key_id") in types:
await conn.execute(text("""
ALTER TABLE audit_logs ADD CONSTRAINT audit_logs_api_key_id_fkey
FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL
"""))
if ("rate_limits", "api_key_id") in types:
await conn.execute(text("""
ALTER TABLE rate_limits ADD CONSTRAINT rate_limits_api_key_id_fkey
FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE
"""))
async def initialize(self) -> None:
async with self.engine.begin() as connection:
if self.engine.dialect.name == "postgresql":
await self._migrate_postgres_uuid_columns(connection)
await connection.run_sync(Base.metadata.create_all)
async def close(self) -> None:
await self.engine.dispose()
@asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
async with self.session_factory() as session:
yield session
|