From 04c8924ab92b92de111baf74900d9f2a77aa7898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 01:12:39 +0300 Subject: [PATCH 1/8] feat: add secure Yandex OAuth web flow --- app/bootstrap/application.py | 2 + app/bootstrap/dependencies.py | 26 ++ app/config.py | 1 + .../versions/20260905_0007_yandex_oauth.py | 123 ++++++++ app/db/models.py | 51 ++++ app/modules/integrations/__init__.py | 0 app/modules/integrations/yandex/__init__.py | 0 app/modules/integrations/yandex/oauth.py | 288 ++++++++++++++++++ app/modules/integrations/yandex/provider.py | 150 +++++++++ app/modules/integrations/yandex/repository.py | 183 +++++++++++ app/modules/integrations/yandex/router.py | 152 +++++++++ docs/openapi.json | 98 ++++++ tests/integration/test_yandex_oauth.py | 241 +++++++++++++++ tests/integration/test_yandex_oauth_http.py | 259 ++++++++++++++++ tests/test_yandex_oauth_provider.py | 114 +++++++ tests/test_yandex_oauth_service.py | 185 +++++++++++ 16 files changed, 1873 insertions(+) create mode 100644 app/db/migrations/versions/20260905_0007_yandex_oauth.py create mode 100644 app/modules/integrations/__init__.py create mode 100644 app/modules/integrations/yandex/__init__.py create mode 100644 app/modules/integrations/yandex/oauth.py create mode 100644 app/modules/integrations/yandex/provider.py create mode 100644 app/modules/integrations/yandex/repository.py create mode 100644 app/modules/integrations/yandex/router.py create mode 100644 tests/integration/test_yandex_oauth.py create mode 100644 tests/integration/test_yandex_oauth_http.py create mode 100644 tests/test_yandex_oauth_provider.py create mode 100644 tests/test_yandex_oauth_service.py diff --git a/app/bootstrap/application.py b/app/bootstrap/application.py index 9cf940b..614268d 100644 --- a/app/bootstrap/application.py +++ b/app/bootstrap/application.py @@ -8,6 +8,7 @@ from app.core.request_context import RequestContextMiddleware from app.core.security_headers import SecurityHeadersMiddleware from app.modules.identity.router import router as identity_router +from app.modules.integrations.yandex.router import router as yandex_oauth_router from app.modules.sessions.middleware import ( CookieCsrfMiddleware, SessionWorkspaceContextMiddleware, @@ -29,5 +30,6 @@ def create_app(*, dependencies: ApplicationDependencies | None = None) -> FastAP app.add_middleware(CookieCsrfMiddleware) app.add_middleware(SecurityHeadersMiddleware) app.include_router(identity_router) + app.include_router(yandex_oauth_router) install_error_handlers(app) return app diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py index 4cc622f..ad3f8f2 100644 --- a/app/bootstrap/dependencies.py +++ b/app/bootstrap/dependencies.py @@ -15,6 +15,14 @@ ) from app.db.schema import check_schema_compatibility from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.oauth import ( + YandexOAuthConfiguration, + YandexOAuthIntegration, +) +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresOAuthTransactionRepository, +) from app.modules.sessions.service import PostgresSessionService from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer from app.providers.protocols import ( @@ -50,6 +58,7 @@ class ApplicationDependencies: identity_repository: PostgresIdentityRepository | None = None session_service: PostgresSessionService | None = None workspace_authorizer: PostgresWorkspaceAuthorizer | None = None + yandex_oauth: YandexOAuthIntegration | None = None fake_auth_enabled: bool = False @@ -59,10 +68,22 @@ def _fake_auth_is_enabled(app_env: str) -> bool: ).lower() == "1" +def _configured_yandex_oauth() -> YandexOAuthConfiguration | None: + settings = get_settings() + if settings.yandex_client_id is None or settings.yandex_client_secret is None: + return None + return YandexOAuthConfiguration( + client_id=settings.yandex_client_id, + client_secret=settings.yandex_client_secret, + redirect_uri=settings.yandex_oauth_redirect_uri, + ) + + def create_application_dependencies() -> ApplicationDependencies: database_url = os.environ.get(DATABASE_URL_ENV) app_env = os.environ.get("DIRECTPILOT_APP_ENV", "local").lower() if database_url: + oauth_config = _configured_yandex_oauth() runtime = create_database_runtime( DatabaseSettings.from_mapping({DATABASE_URL_ENV: database_url}) ) @@ -80,6 +101,11 @@ def create_application_dependencies() -> ApplicationDependencies: identity_repository=PostgresIdentityRepository(runtime.sessions), session_service=PostgresSessionService(runtime.sessions), workspace_authorizer=PostgresWorkspaceAuthorizer(runtime.sessions), + yandex_oauth=YandexOAuthIntegration( + transactions=PostgresOAuthTransactionRepository(runtime.sessions), + identities=PostgresExternalIdentityRepository(runtime.sessions), + config=oauth_config, + ), fake_auth_enabled=_fake_auth_is_enabled(app_env), ) if app_env in {"production", "staging"}: diff --git a/app/config.py b/app/config.py index 3882163..c213a37 100644 --- a/app/config.py +++ b/app/config.py @@ -38,6 +38,7 @@ class Settings(BaseSettings): yandex_client_secret: str | None = None yandex_oauth_token: str | None = None yandex_redirect_uri: str = "https://oauth.yandex.ru/verification_code" + yandex_oauth_redirect_uri: str = "http://127.0.0.1:8000/api/v1/integrations/yandex/callback" # Yandex AI Studio / Search API v2 — used by the modern Wordstat client. # Optional folderId is the cloud folder that owns the service account diff --git a/app/db/migrations/versions/20260905_0007_yandex_oauth.py b/app/db/migrations/versions/20260905_0007_yandex_oauth.py new file mode 100644 index 0000000..ffe3864 --- /dev/null +++ b/app/db/migrations/versions/20260905_0007_yandex_oauth.py @@ -0,0 +1,123 @@ +"""P4 Yandex OAuth transactions and external identity mapping.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260905_0007" +down_revision = "20260904_0006" +branch_labels = None +depends_on = None + +_WORKSPACE_SETTING = "NULLIF(current_setting('app.current_workspace_id', true), '')::uuid" +_USER_SETTING = "NULLIF(current_setting('app.current_user_id', true), '')::uuid" +_MEMBERSHIP_CHECK = f"public.directpilot_has_active_membership({_WORKSPACE_SETTING}, {_USER_SETTING})" + + +def _enable_oauth_transaction_rls() -> None: + predicate = ( + f"workspace_id = {_WORKSPACE_SETTING} AND user_id = {_USER_SETTING} " + f"AND {_MEMBERSHIP_CHECK}" + ) + op.execute("ALTER TABLE yandex_oauth_transactions ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE yandex_oauth_transactions FORCE ROW LEVEL SECURITY") + op.execute( + "CREATE POLICY yandex_oauth_transactions_workspace_isolation " + "ON yandex_oauth_transactions FOR ALL TO directpilot_app " + f"USING ({predicate}) WITH CHECK ({predicate})" + ) + + +def _enable_external_identity_rls() -> None: + predicate = f"user_id = {_USER_SETTING}" + op.execute("ALTER TABLE external_identities ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE external_identities FORCE ROW LEVEL SECURITY") + op.execute( + "CREATE POLICY external_identities_user_isolation " + "ON external_identities FOR ALL TO directpilot_app " + f"USING ({predicate}) WITH CHECK ({predicate})" + ) + + +def upgrade() -> None: + op.create_table( + "yandex_oauth_transactions", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("state_hash", sa.String(length=64), nullable=False), + sa.Column("code_verifier", sa.String(length=128), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("browser_session_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("return_path", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], name=op.f("fk_yandex_oauth_transactions_user_id_users") + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.id"], + name=op.f("fk_yandex_oauth_transactions_workspace_id_workspaces"), + ), + sa.ForeignKeyConstraint( + ["browser_session_id"], + ["sessions.id"], + name=op.f("fk_yandex_oauth_transactions_browser_session_id_sessions"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_yandex_oauth_transactions")), + sa.UniqueConstraint("state_hash", name=op.f("uq_yandex_oauth_transactions_state_hash")), + ) + op.create_index( + op.f("ix_yandex_oauth_transactions_expires_at"), + "yandex_oauth_transactions", + ["expires_at"], + ) + + op.create_table( + "external_identities", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("issuer", sa.String(length=255), nullable=False), + sa.Column("subject", sa.String(length=255), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("profile_login", sa.String(length=255), nullable=True), + sa.Column("profile_display_name", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_authenticated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], name=op.f("fk_external_identities_user_id_users") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_external_identities")), + sa.UniqueConstraint("issuer", "subject", name=op.f("uq_external_identities_issuer")), + sa.UniqueConstraint("user_id", "issuer", name=op.f("uq_external_identities_user_id")), + ) + + for table_name in ("yandex_oauth_transactions", "external_identities"): + op.execute(f"REVOKE ALL ON TABLE {table_name} FROM PUBLIC") + op.execute(f"GRANT SELECT, INSERT, UPDATE ON TABLE {table_name} TO directpilot_app") + + _enable_oauth_transaction_rls() + _enable_external_identity_rls() + + +def downgrade() -> None: + op.execute( + "DROP POLICY IF EXISTS external_identities_user_isolation ON external_identities" + ) + op.execute("ALTER TABLE external_identities NO FORCE ROW LEVEL SECURITY") + op.execute("ALTER TABLE external_identities DISABLE ROW LEVEL SECURITY") + op.execute( + "DROP POLICY IF EXISTS yandex_oauth_transactions_workspace_isolation " + "ON yandex_oauth_transactions" + ) + op.execute("ALTER TABLE yandex_oauth_transactions NO FORCE ROW LEVEL SECURITY") + op.execute("ALTER TABLE yandex_oauth_transactions DISABLE ROW LEVEL SECURITY") + op.drop_table("external_identities") + op.drop_index( + op.f("ix_yandex_oauth_transactions_expires_at"), + table_name="yandex_oauth_transactions", + ) + op.drop_table("yandex_oauth_transactions") diff --git a/app/db/models.py b/app/db/models.py index 5b4113c..ee80044 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -148,6 +148,57 @@ class SessionRecord(Base): revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) +class OAuthTransactionRecord(Base): + __tablename__ = "yandex_oauth_transactions" + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + state_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + code_verifier: Mapped[str] = mapped_column(String(128), nullable=False) + user_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False, + ) + workspace_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + nullable=False, + ) + browser_session_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("sessions.id"), + nullable=False, + ) + return_path: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class ExternalIdentityRecord(Base): + __tablename__ = "external_identities" + __table_args__ = ( + UniqueConstraint("issuer", "subject"), + UniqueConstraint("user_id", "issuer"), + ) + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + issuer: Mapped[str] = mapped_column(String(255), nullable=False) + subject: Mapped[str] = mapped_column(String(255), nullable=False) + user_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False, + ) + profile_login: Mapped[str | None] = mapped_column(String(255), nullable=True) + profile_display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_authenticated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + class AuditEventRecord(Base): __tablename__ = "audit_events" diff --git a/app/modules/integrations/__init__.py b/app/modules/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/integrations/yandex/__init__.py b/app/modules/integrations/yandex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/integrations/yandex/oauth.py b/app/modules/integrations/yandex/oauth.py new file mode 100644 index 0000000..0be90a3 --- /dev/null +++ b/app/modules/integrations/yandex/oauth.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Protocol +from urllib.parse import urlencode +from uuid import UUID + +YANDEX_AUTHORIZE_URL = "https://oauth.yandex.ru/authorize" +LOCAL_YANDEX_CALLBACK_URI = "http://127.0.0.1:8000/api/v1/integrations/yandex/callback" +MAX_OAUTH_TRANSACTION_TTL = timedelta(minutes=10) +_ALLOWED_RETURN_PATHS = frozenset({"/"}) + + +class OAuthConfigurationError(ValueError): + """Raised when the bounded local OAuth configuration is unsafe.""" + + +@dataclass(frozen=True, slots=True) +class YandexOAuthConfiguration: + client_id: str = field(repr=False) + client_secret: str = field(repr=False) + redirect_uri: str + transaction_ttl: timedelta = MAX_OAUTH_TRANSACTION_TTL + + def __post_init__(self) -> None: + if not self.client_id or not self.client_secret: + raise OAuthConfigurationError("Yandex OAuth is not configured") + if self.redirect_uri != LOCAL_YANDEX_CALLBACK_URI: + raise OAuthConfigurationError("Yandex OAuth redirect URI is not configured") + if not timedelta() < self.transaction_ttl <= MAX_OAUTH_TRANSACTION_TTL: + raise OAuthConfigurationError("Yandex OAuth transaction TTL is not configured") + + +@dataclass(frozen=True, slots=True) +class NewOAuthTransaction: + state_hash: str + code_verifier: str = field(repr=False) + user_id: UUID + workspace_id: UUID + browser_session_id: UUID + return_path: str + created_at: datetime + expires_at: datetime + + +@dataclass(frozen=True, slots=True) +class ConsumedOAuthTransaction: + id: UUID + code_verifier: str = field(repr=False) + user_id: UUID + workspace_id: UUID + browser_session_id: UUID + return_path: str + expires_at: datetime + consumed_at: datetime + + +@dataclass(frozen=True, slots=True) +class OAuthStart: + authorization_url: str = field(repr=False) + expires_at: datetime + + +class OAuthTransactionWriter(Protocol): + def create(self, transaction: NewOAuthTransaction) -> None: ... + + +class OAuthTransactionConsumer(Protocol): + def consume( + self, + *, + state: str, + user_id: UUID, + workspace_id: UUID, + browser_session_id: UUID, + ) -> ConsumedOAuthTransaction | None: ... + + +class ExternalIdentityBinder(Protocol): + def bind_yandex_identity( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + profile_login: str | None, + profile_display_name: str | None, + ) -> object: ... + + +@dataclass(frozen=True, slots=True) +class YandexOAuthTokenSet: + access_token: str = field(repr=False) + refresh_token: str = field(repr=False) + expires_in: int + token_type: str = "bearer" + + +@dataclass(frozen=True, slots=True) +class YandexUserInfo: + subject: str = field(repr=False) + client_id: str = field(repr=False) + login: str | None = field(default=None, repr=False) + display_name: str | None = field(default=None, repr=False) + + +class YandexOAuthProvider(Protocol): + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: ... + + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: ... + + +class OAuthTransactionRepository( + OAuthTransactionWriter, + OAuthTransactionConsumer, + Protocol, +): + pass + + +@dataclass(frozen=True, slots=True) +class YandexOAuthIntegration: + """Injectable OAuth seams; tests provide a config and mock provider.""" + + transactions: OAuthTransactionRepository + identities: ExternalIdentityBinder + config: YandexOAuthConfiguration | None = None + provider: YandexOAuthProvider | None = None + + +class OAuthCallbackRejected(RuntimeError): + """Raised for an invalid, expired, replayed, or browser-mismatched callback.""" + + +class OAuthClientMismatch(RuntimeError): + """Raised when userinfo does not belong to the configured OAuth application.""" + + +@dataclass(frozen=True, slots=True) +class OAuthCallbackResult: + return_path: str + + +def hash_oauth_state(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def _pkce_s256_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _utc_now(value: datetime | None) -> datetime: + now = datetime.now(timezone.utc) if value is None else value + if now.tzinfo is None: + raise ValueError("OAuth transaction time must be timezone-aware") + return now.astimezone(timezone.utc) + + +def _validated_return_path(value: str) -> str: + if value not in _ALLOWED_RETURN_PATHS: + raise ValueError("OAuth return path is not allowed") + return value + + +class OAuthStartService: + """Create a short-lived, server-side Yandex OAuth authorization transaction.""" + + def __init__( + self, + *, + config: YandexOAuthConfiguration, + transactions: OAuthTransactionWriter, + ) -> None: + self._config = config + self._transactions = transactions + + def start( + self, + *, + user_id: UUID, + workspace_id: UUID, + browser_session_id: UUID, + return_path: str, + now: datetime | None = None, + ) -> OAuthStart: + created_at = _utc_now(now) + state = secrets.token_urlsafe(32) + verifier = secrets.token_urlsafe(32) + expires_at = created_at + self._config.transaction_ttl + transaction = NewOAuthTransaction( + state_hash=hash_oauth_state(state), + code_verifier=verifier, + user_id=user_id, + workspace_id=workspace_id, + browser_session_id=browser_session_id, + return_path=_validated_return_path(return_path), + created_at=created_at, + expires_at=expires_at, + ) + self._transactions.create(transaction) + # The locally documented Yandex flow supports PKCE; DirectPilot requires S256 only. + parameters = { + "response_type": "code", + "client_id": self._config.client_id, + "redirect_uri": self._config.redirect_uri, + "state": state, + "code_challenge": _pkce_s256_challenge(verifier), + "code_challenge_method": "S256", + } + authorization_url = f"{YANDEX_AUTHORIZE_URL}?{urlencode(parameters)}" + return OAuthStart(authorization_url=authorization_url, expires_at=expires_at) + + +class OAuthCallbackService: + """Spend an OAuth transaction before the mocked/injectable provider boundary.""" + + def __init__( + self, + *, + config: YandexOAuthConfiguration, + transactions: OAuthTransactionConsumer, + identities: ExternalIdentityBinder, + provider: YandexOAuthProvider, + ) -> None: + self._config = config + self._transactions = transactions + self._identities = identities + self._provider = provider + + def complete( + self, + *, + code: str, + state: str, + user_id: UUID, + workspace_id: UUID, + browser_session_id: UUID, + ) -> OAuthCallbackResult: + if not _is_valid_callback_value(code, maximum_length=4096) or not _is_valid_callback_value( + state, maximum_length=1024 + ): + raise OAuthCallbackRejected("OAuth callback is invalid") + transaction = self._transactions.consume( + state=state, + user_id=user_id, + workspace_id=workspace_id, + browser_session_id=browser_session_id, + ) + if transaction is None: + raise OAuthCallbackRejected("OAuth callback is unavailable") + + tokens = self._provider.exchange_code( + code=code, + code_verifier=transaction.code_verifier, + ) + userinfo = self._provider.fetch_user_info(access_token=tokens.access_token) + if not hmac.compare_digest(userinfo.client_id, self._config.client_id): + raise OAuthClientMismatch("Yandex OAuth application is invalid") + if not _is_valid_callback_value(userinfo.subject, maximum_length=255): + raise OAuthCallbackRejected("Yandex OAuth subject is invalid") + self._identities.bind_yandex_identity( + user_id=transaction.user_id, + workspace_id=transaction.workspace_id, + issuer="https://login.yandex.ru", + subject=userinfo.subject, + profile_login=_bounded_profile_value(userinfo.login), + profile_display_name=_bounded_profile_value(userinfo.display_name), + ) + # P4-01/P4-02 deliberately does not persist the transient token pair; P4-03 owns vaulting. + return OAuthCallbackResult(return_path=transaction.return_path) + + +def _is_valid_callback_value(value: str, *, maximum_length: int) -> bool: + return bool(value) and value.isascii() and len(value) <= maximum_length + + +def _bounded_profile_value(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized[:255] or None diff --git a/app/modules/integrations/yandex/provider.py b/app/modules/integrations/yandex/provider.py new file mode 100644 index 0000000..aa7bb28 --- /dev/null +++ b/app/modules/integrations/yandex/provider.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx + +from app.modules.integrations.yandex.oauth import ( + YandexOAuthConfiguration, + YandexOAuthTokenSet, + YandexUserInfo, +) + +_TOKEN_URL = "https://oauth.yandex.ru/token" +_USERINFO_URL = "https://login.yandex.ru/info" +_KNOWN_TOKEN_ERRORS = frozenset( + { + "invalid_client", + "invalid_grant", + "invalid_request", + "invalid_scope", + "unauthorized_client", + "unsupported_grant_type", + } +) + + +class YandexOAuthProviderFailure(RuntimeError): + """A safe provider outcome that intentionally omits upstream body content.""" + + def __init__(self, kind: str) -> None: + self.kind = kind + super().__init__("Yandex OAuth provider request failed") + + +class HttpxYandexOAuthProvider: + """Synchronous HTTP boundary for Yandex OAuth; all tests inject a mock transport.""" + + def __init__( + self, + *, + config: YandexOAuthConfiguration, + transport: httpx.BaseTransport | None = None, + ) -> None: + self._config = config + self._transport = transport + + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: + try: + with httpx.Client( + timeout=httpx.Timeout(10.0), + transport=self._transport, + ) as client: + response = client.post( + _TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": code, + "code_verifier": code_verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + auth=(self._config.client_id, self._config.client_secret), + ) + except httpx.HTTPError: + raise YandexOAuthProviderFailure("provider_unavailable") from None + if response.is_error: + raise _token_failure(response) + payload = _json_mapping(response, kind="provider_response_invalid") + token_type = payload.get("token_type") + access_token = payload.get("access_token") + refresh_token = payload.get("refresh_token") + expires_in = payload.get("expires_in") + if ( + not isinstance(token_type, str) + or token_type.lower() != "bearer" + or not isinstance(access_token, str) + or not access_token + or not isinstance(refresh_token, str) + or not refresh_token + or not isinstance(expires_in, int) + or isinstance(expires_in, bool) + or expires_in <= 0 + ): + raise YandexOAuthProviderFailure("provider_response_invalid") + return YandexOAuthTokenSet( + access_token=access_token, + refresh_token=refresh_token, + expires_in=expires_in, + token_type=token_type.lower(), + ) + + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: + try: + with httpx.Client( + timeout=httpx.Timeout(10.0), + transport=self._transport, + ) as client: + response = client.get( + _USERINFO_URL, + params={"format": "json"}, + headers={"Authorization": f"OAuth {access_token}"}, + ) + except httpx.HTTPError: + raise YandexOAuthProviderFailure("provider_unavailable") from None + if response.is_error: + raise YandexOAuthProviderFailure("userinfo_failed") + payload = _json_mapping(response, kind="userinfo_invalid") + subject = payload.get("id") + client_id = payload.get("client_id") + if not isinstance(subject, str) or not subject or not isinstance(client_id, str) or not client_id: + raise YandexOAuthProviderFailure("userinfo_invalid") + return YandexUserInfo( + subject=subject, + client_id=client_id, + login=_optional_text(payload.get("login")), + display_name=_profile_display_name(payload), + ) + + +def _json_mapping(response: httpx.Response, *, kind: str) -> Mapping[str, Any]: + try: + payload = response.json() + except ValueError: + raise YandexOAuthProviderFailure(kind) from None + if not isinstance(payload, Mapping): + raise YandexOAuthProviderFailure(kind) + return payload + + +def _token_failure(response: httpx.Response) -> YandexOAuthProviderFailure: + try: + payload = response.json() + except ValueError: + return YandexOAuthProviderFailure("provider_error") + error = payload.get("error") if isinstance(payload, Mapping) else None + if isinstance(error, str) and error in _KNOWN_TOKEN_ERRORS: + return YandexOAuthProviderFailure(error) + return YandexOAuthProviderFailure("provider_error") + + +def _optional_text(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _profile_display_name(payload: Mapping[str, Any]) -> str | None: + for field in ("display_name", "real_name"): + value = _optional_text(payload.get(field)) + if value is not None: + return value + return None diff --git a/app/modules/integrations/yandex/repository.py b/app/modules/integrations/yandex/repository.py new file mode 100644 index 0000000..4525b99 --- /dev/null +++ b/app/modules/integrations/yandex/repository.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import ExternalIdentityRecord, OAuthTransactionRecord +from app.db.rls import tenant_transaction +from app.modules.integrations.yandex.oauth import ( + ConsumedOAuthTransaction, + NewOAuthTransaction, + hash_oauth_state, +) + + +class ExternalIdentityConflict(RuntimeError): + """Raised when a Yandex subject would rebind a different local user.""" + + +@dataclass(frozen=True, slots=True) +class BoundExternalIdentity: + user_id: UUID + issuer: str = field(repr=False) + subject: str = field(repr=False) + + +class PostgresOAuthTransactionRepository: + """Durably stores and atomically spends browser-bound OAuth transactions.""" + + def __init__(self, sessions: sessionmaker[Session]) -> None: + self._sessions = sessions + + def create(self, transaction: NewOAuthTransaction) -> None: + with tenant_transaction( + self._sessions, + workspace_id=transaction.workspace_id, + user_id=transaction.user_id, + ) as session: + session.add( + OAuthTransactionRecord( + id=uuid4(), + state_hash=transaction.state_hash, + code_verifier=transaction.code_verifier, + user_id=transaction.user_id, + workspace_id=transaction.workspace_id, + browser_session_id=transaction.browser_session_id, + return_path=transaction.return_path, + created_at=transaction.created_at, + expires_at=transaction.expires_at, + consumed_at=None, + ) + ) + session.flush() + + def consume( + self, + *, + state: str, + user_id: UUID, + workspace_id: UUID, + browser_session_id: UUID, + now: datetime | None = None, + ) -> ConsumedOAuthTransaction | None: + consumed_at = _utc_now(now) + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + record = session.scalar( + select(OAuthTransactionRecord) + .where( + OAuthTransactionRecord.state_hash == hash_oauth_state(state), + OAuthTransactionRecord.user_id == user_id, + OAuthTransactionRecord.workspace_id == workspace_id, + OAuthTransactionRecord.browser_session_id == browser_session_id, + OAuthTransactionRecord.consumed_at.is_(None), + OAuthTransactionRecord.expires_at > consumed_at, + ) + .with_for_update() + ) + if record is None: + return None + record.consumed_at = consumed_at + session.flush() + return ConsumedOAuthTransaction( + id=record.id, + code_verifier=record.code_verifier, + user_id=record.user_id, + workspace_id=record.workspace_id, + browser_session_id=record.browser_session_id, + return_path=record.return_path, + expires_at=record.expires_at, + consumed_at=consumed_at, + ) + + +class PostgresExternalIdentityRepository: + """Maps a stable issuer/subject pair to exactly one existing local user.""" + + def __init__(self, sessions: sessionmaker[Session]) -> None: + self._sessions = sessions + + def bind_yandex_identity( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + profile_login: str | None, + profile_display_name: str | None, + now: datetime | None = None, + ) -> BoundExternalIdentity: + if not issuer or not subject: + raise ValueError("external identity is invalid") + observed_at = _utc_now(now) + try: + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + user_identity = session.scalar( + select(ExternalIdentityRecord) + .where( + ExternalIdentityRecord.user_id == user_id, + ExternalIdentityRecord.issuer == issuer, + ) + .with_for_update() + ) + if user_identity is not None and user_identity.subject != subject: + raise ExternalIdentityConflict("external identity is already bound") + + subject_identity = session.scalar( + select(ExternalIdentityRecord) + .where( + ExternalIdentityRecord.issuer == issuer, + ExternalIdentityRecord.subject == subject, + ) + .with_for_update() + ) + if subject_identity is not None and subject_identity.user_id != user_id: + raise ExternalIdentityConflict("external identity is already bound") + + record = user_identity or subject_identity + if record is None: + record = ExternalIdentityRecord( + id=uuid4(), + issuer=issuer, + subject=subject, + user_id=user_id, + profile_login=profile_login, + profile_display_name=profile_display_name, + created_at=observed_at, + updated_at=observed_at, + last_authenticated_at=observed_at, + ) + session.add(record) + else: + record.profile_login = profile_login + record.profile_display_name = profile_display_name + record.updated_at = observed_at + record.last_authenticated_at = observed_at + session.flush() + return BoundExternalIdentity( + user_id=record.user_id, + issuer=record.issuer, + subject=record.subject, + ) + except IntegrityError: + raise ExternalIdentityConflict("external identity is already bound") from None + + +def _utc_now(value: datetime | None) -> datetime: + now = datetime.now(timezone.utc) if value is None else value + if now.tzinfo is None: + raise ValueError("external identity time must be timezone-aware") + return now.astimezone(timezone.utc) diff --git a/app/modules/integrations/yandex/router.py b/app/modules/integrations/yandex/router.py new file mode 100644 index 0000000..04951e3 --- /dev/null +++ b/app/modules/integrations/yandex/router.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from fastapi import APIRouter, Request +from starlette.responses import RedirectResponse, Response + +from app.bootstrap.dependencies import get_application_dependencies +from app.core.errors import safe_error_response +from app.modules.integrations.yandex.oauth import ( + OAuthCallbackRejected, + OAuthCallbackService, + OAuthClientMismatch, + OAuthStartService, + YandexOAuthConfiguration, + YandexOAuthIntegration, + YandexOAuthProvider, +) +from app.modules.integrations.yandex.provider import ( + HttpxYandexOAuthProvider, + YandexOAuthProviderFailure, +) +from app.modules.integrations.yandex.repository import ExternalIdentityConflict +from app.modules.sessions.cookies import SESSION_COOKIE_NAME, set_session_cookie +from app.modules.sessions.service import AuthenticatedSession, PostgresSessionService +from app.modules.tenancy.policy import AuthorizationDenied, Capability + +router = APIRouter() + + +@dataclass(frozen=True, slots=True) +class _OAuthBrowserSession: + token: str = field(repr=False) + principal: AuthenticatedSession + service: PostgresSessionService + + +def _safe_error(request: Request, *, status_code: int, code: str) -> Response: + return safe_error_response(request=request, status_code=status_code, code=code) + + +def _authenticated_oauth_session(request: Request) -> _OAuthBrowserSession | Response: + dependencies = get_application_dependencies(request) + session_service = dependencies.session_service + authorizer = dependencies.workspace_authorizer + token = request.cookies.get(SESSION_COOKIE_NAME) + if token is None or session_service is None or authorizer is None: + return _safe_error(request, status_code=401, code="oauth_authentication_required") + principal = session_service.authenticate(token, touch=False) + if principal is None or principal.active_workspace_id is None: + return _safe_error(request, status_code=401, code="oauth_authentication_required") + try: + authorizer.authorize( + user_id=principal.user_id, + workspace_id=principal.active_workspace_id, + capability=Capability.MANAGE_PROVIDER_CONNECTIONS, + ) + except AuthorizationDenied: + return _safe_error(request, status_code=403, code="oauth_workspace_access_denied") + return _OAuthBrowserSession(token=token, principal=principal, service=session_service) + + +def _configured_integration( + request: Request, +) -> tuple[YandexOAuthIntegration, YandexOAuthConfiguration] | Response: + integration = get_application_dependencies(request).yandex_oauth + if integration is None or integration.config is None: + return _safe_error(request, status_code=503, code="oauth_unavailable") + return integration, integration.config + + +def _provider( + integration: YandexOAuthIntegration, + config: YandexOAuthConfiguration, +) -> YandexOAuthProvider: + return integration.provider or HttpxYandexOAuthProvider(config=config) + + +@router.get("/api/v1/integrations/yandex/start") +def start_yandex_oauth(request: Request, return_path: str = "/") -> Response: + browser = _authenticated_oauth_session(request) + if isinstance(browser, Response): + return browser + configured = _configured_integration(request) + if isinstance(configured, Response): + return configured + integration, config = configured + workspace_id = browser.principal.active_workspace_id + if workspace_id is None: + return _safe_error(request, status_code=401, code="oauth_authentication_required") + try: + started = OAuthStartService(config=config, transactions=integration.transactions).start( + user_id=browser.principal.user_id, + workspace_id=workspace_id, + browser_session_id=browser.principal.id, + return_path=return_path, + ) + except ValueError: + return _safe_error(request, status_code=400, code="oauth_start_rejected") + return RedirectResponse(url=started.authorization_url, status_code=302) + + +@router.get("/api/v1/integrations/yandex/callback") +def complete_yandex_oauth( + request: Request, + code: str | None = None, + state: str | None = None, +) -> Response: + if code is None or state is None: + return _safe_error(request, status_code=400, code="oauth_callback_rejected") + browser = _authenticated_oauth_session(request) + if isinstance(browser, Response): + return browser + configured = _configured_integration(request) + if isinstance(configured, Response): + return configured + integration, config = configured + workspace_id = browser.principal.active_workspace_id + if workspace_id is None: + return _safe_error(request, status_code=401, code="oauth_authentication_required") + try: + completed = OAuthCallbackService( + config=config, + transactions=integration.transactions, + identities=integration.identities, + provider=_provider(integration, config), + ).complete( + code=code, + state=state, + user_id=browser.principal.user_id, + workspace_id=workspace_id, + browser_session_id=browser.principal.id, + ) + except OAuthCallbackRejected: + return _safe_error(request, status_code=400, code="oauth_callback_rejected") + except OAuthClientMismatch: + return _safe_error(request, status_code=502, code="oauth_provider_error") + except ExternalIdentityConflict: + return _safe_error(request, status_code=409, code="oauth_identity_conflict") + except YandexOAuthProviderFailure as exc: + if exc.kind == "invalid_grant": + return _safe_error(request, status_code=400, code="oauth_reauthorization_required") + if exc.kind in {"invalid_client", "unauthorized_client"}: + return _safe_error(request, status_code=503, code="oauth_provider_configuration_error") + return _safe_error(request, status_code=502, code="oauth_provider_error") + + rotated = browser.service.rotate(browser.token) + if rotated is None: + return _safe_error(request, status_code=401, code="oauth_session_rotation_failed") + response = RedirectResponse(url=completed.return_path, status_code=303) + set_session_cookie(response, token=rotated.token, expires_at=rotated.expires_at) + return response diff --git a/docs/openapi.json b/docs/openapi.json index a75e269..b83feee 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -8446,6 +8446,104 @@ "summary": "Apply Action" } }, + "/api/v1/integrations/yandex/callback": { + "get": { + "operationId": "complete_yandex_oauth_api_v1_integrations_yandex_callback_get", + "parameters": [ + { + "in": "query", + "name": "code", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + } + }, + { + "in": "query", + "name": "state", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Complete Yandex Oauth" + } + }, + "/api/v1/integrations/yandex/start": { + "get": { + "operationId": "start_yandex_oauth_api_v1_integrations_yandex_start_get", + "parameters": [ + { + "in": "query", + "name": "return_path", + "required": false, + "schema": { + "default": "/", + "title": "Return Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Start Yandex Oauth" + } + }, "/audit-log": { "get": { "operationId": "audit_log_audit_log_get", diff --git a/tests/integration/test_yandex_oauth.py b/tests/integration/test_yandex_oauth.py new file mode 100644 index 0000000..65b1d9a --- /dev/null +++ b/tests/integration/test_yandex_oauth.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import hmac +import secrets +from datetime import datetime, timedelta, timezone + +import pytest + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.oauth import NewOAuthTransaction, hash_oauth_state +from app.modules.integrations.yandex.repository import ( + ExternalIdentityConflict, + PostgresExternalIdentityRepository, + PostgresOAuthTransactionRepository, +) +from app.modules.sessions.service import PostgresSessionService + + +@pytest.mark.integration +def test_durable_oauth_transaction_is_consumed_only_once( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime(2026, 9, 5, tzinfo=timezone.utc) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic OAuth owner", + workspace_name="Synthetic OAuth workspace", + ) + browser_session = PostgresSessionService(app_runtime.sessions).issue( + user_id=identity.user.id, + active_workspace_id=identity.workspace.id, + now=now, + ) + state = secrets.token_urlsafe(32) + verifier = secrets.token_urlsafe(32) + transactions = PostgresOAuthTransactionRepository(app_runtime.sessions) + transactions.create( + NewOAuthTransaction( + state_hash=hash_oauth_state(state), + code_verifier=verifier, + user_id=identity.user.id, + workspace_id=identity.workspace.id, + browser_session_id=browser_session.id, + return_path="/", + created_at=now, + expires_at=now + timedelta(minutes=5), + ) + ) + + consumed = transactions.consume( + state=state, + user_id=identity.user.id, + workspace_id=identity.workspace.id, + browser_session_id=browser_session.id, + now=now, + ) + + assert consumed is not None + assert hmac.compare_digest(consumed.code_verifier, verifier) + assert ( + transactions.consume( + state=state, + user_id=identity.user.id, + workspace_id=identity.workspace.id, + browser_session_id=browser_session.id, + now=now, + ) + is None + ) + finally: + owner_runtime.close() + app_runtime.close() + + +@pytest.mark.integration +def test_external_identity_cannot_rebind_a_subject_or_local_user( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identities = PostgresIdentityRepository(app_runtime.sessions) + first = identities.create_personal_workspace( + display_name="Synthetic first identity owner", + workspace_name="Synthetic first identity workspace", + ) + second = identities.create_personal_workspace( + display_name="Synthetic second identity owner", + workspace_name="Synthetic second identity workspace", + ) + external_identities = PostgresExternalIdentityRepository(app_runtime.sessions) + external_identities.bind_yandex_identity( + user_id=first.user.id, + workspace_id=first.workspace.id, + issuer="https://login.yandex.ru", + subject="stable-yandex-user-id", + profile_login="synthetic-login", + profile_display_name="Synthetic display name", + ) + + with pytest.raises(ExternalIdentityConflict): + external_identities.bind_yandex_identity( + user_id=second.user.id, + workspace_id=second.workspace.id, + issuer="https://login.yandex.ru", + subject="stable-yandex-user-id", + profile_login="synthetic-login", + profile_display_name="Synthetic display name", + ) + with pytest.raises(ExternalIdentityConflict): + external_identities.bind_yandex_identity( + user_id=first.user.id, + workspace_id=first.workspace.id, + issuer="https://login.yandex.ru", + subject="different-stable-yandex-user-id", + profile_login="synthetic-login", + profile_display_name="Synthetic display name", + ) + finally: + owner_runtime.close() + app_runtime.close() + + +@pytest.mark.integration +def test_durable_oauth_transaction_rejects_expiry_and_browser_mismatch( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime(2026, 9, 5, tzinfo=timezone.utc) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic rejected OAuth owner", + workspace_name="Synthetic rejected OAuth workspace", + ) + sessions = PostgresSessionService(app_runtime.sessions) + bound_browser = sessions.issue( + user_id=identity.user.id, + active_workspace_id=identity.workspace.id, + now=now, + ) + other_browser = sessions.issue( + user_id=identity.user.id, + active_workspace_id=identity.workspace.id, + now=now, + ) + transactions = PostgresOAuthTransactionRepository(app_runtime.sessions) + expired_state = secrets.token_urlsafe(32) + transactions.create( + NewOAuthTransaction( + state_hash=hash_oauth_state(expired_state), + code_verifier=secrets.token_urlsafe(32), + user_id=identity.user.id, + workspace_id=identity.workspace.id, + browser_session_id=bound_browser.id, + return_path="/", + created_at=now - timedelta(minutes=10), + expires_at=now, + ) + ) + mismatch_state = secrets.token_urlsafe(32) + transactions.create( + NewOAuthTransaction( + state_hash=hash_oauth_state(mismatch_state), + code_verifier=secrets.token_urlsafe(32), + user_id=identity.user.id, + workspace_id=identity.workspace.id, + browser_session_id=bound_browser.id, + return_path="/", + created_at=now, + expires_at=now + timedelta(minutes=5), + ) + ) + + assert ( + transactions.consume( + state=expired_state, + user_id=identity.user.id, + workspace_id=identity.workspace.id, + browser_session_id=bound_browser.id, + now=now, + ) + is None + ) + assert ( + transactions.consume( + state=mismatch_state, + user_id=identity.user.id, + workspace_id=identity.workspace.id, + browser_session_id=other_browser.id, + now=now, + ) + is None + ) + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_yandex_oauth_http.py b/tests/integration/test_yandex_oauth_http.py new file mode 100644 index 0000000..303e7c1 --- /dev/null +++ b/tests/integration/test_yandex_oauth_http.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import hmac +import logging +import secrets +from dataclasses import replace +from urllib.parse import parse_qs, urlsplit +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import create_application_dependencies +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import ExternalIdentityRecord +from app.db.rls import tenant_transaction +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.oauth import ( + YandexOAuthConfiguration, + YandexOAuthIntegration, + YandexOAuthTokenSet, + YandexUserInfo, +) +from app.modules.integrations.yandex.provider import YandexOAuthProviderFailure +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresOAuthTransactionRepository, +) +from app.modules.sessions.cookies import SESSION_COOKIE_NAME +from app.modules.sessions.service import PostgresSessionService +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +class _MockYandexOAuthProvider: + def __init__(self, config: YandexOAuthConfiguration) -> None: + self._config = config + self.exchange_calls = 0 + self.userinfo_calls = 0 + + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: + assert len(code) >= 43 + assert len(code_verifier) >= 43 + self.exchange_calls += 1 + return YandexOAuthTokenSet( + access_token=secrets.token_urlsafe(32), + refresh_token=secrets.token_urlsafe(32), + expires_in=3600, + ) + + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: + assert len(access_token) >= 43 + self.userinfo_calls += 1 + return YandexUserInfo( + subject="stable-yandex-user-id", + client_id=self._config.client_id, + login="synthetic-login", + display_name="Synthetic display name", + ) + + +@pytest.mark.integration +def test_authenticated_oauth_callback_rotates_session_and_maps_stable_subject( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + provider = _MockYandexOAuthProvider(config) + session_service = PostgresSessionService(app_runtime.sessions) + dependencies = replace( + create_application_dependencies(), + repository=PostgresLegacyStoreRepository(app_runtime.sessions), + identity_repository=PostgresIdentityRepository(app_runtime.sessions), + session_service=session_service, + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + yandex_oauth=YandexOAuthIntegration( + config=config, + transactions=PostgresOAuthTransactionRepository(app_runtime.sessions), + identities=PostgresExternalIdentityRepository(app_runtime.sessions), + provider=provider, + ), + ) + client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") + + login = client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic OAuth HTTP owner"}, + ) + assert login.status_code == 200 + previous_session_token = client.cookies.get(SESSION_COOKIE_NAME) + assert previous_session_token is not None + user_id = login.json()["user_id"] + workspace_id = login.json()["workspace_id"] + + start = client.get( + "/api/v1/integrations/yandex/start?return_path=/", + follow_redirects=False, + ) + assert start.status_code == 302 + state = parse_qs(urlsplit(start.headers["location"]).query, strict_parsing=True)[ + "state" + ][0] + callback = client.get( + "/api/v1/integrations/yandex/callback", + params={"code": secrets.token_urlsafe(32), "state": state}, + follow_redirects=False, + ) + + assert callback.status_code == 303 + assert callback.headers["location"] == "/" + rotated_session_token = client.cookies.get(SESSION_COOKIE_NAME) + assert rotated_session_token is not None + assert not hmac.compare_digest(previous_session_token, rotated_session_token) + assert session_service.authenticate(previous_session_token) is None + assert session_service.authenticate(rotated_session_token) is not None + assert provider.exchange_calls == 1 + assert provider.userinfo_calls == 1 + with tenant_transaction( + app_runtime.sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + identity = session.scalar(select(ExternalIdentityRecord)) + assert identity is not None + assert identity.subject == "stable-yandex-user-id" + assert identity.user_id == UUID(login.json()["user_id"]) + finally: + owner_runtime.close() + app_runtime.close() + + +class _FailingYandexOAuthProvider: + def __init__(self) -> None: + self.exchange_calls = 0 + self.userinfo_calls = 0 + + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: + assert len(code) >= 43 + assert len(code_verifier) >= 43 + self.exchange_calls += 1 + raise YandexOAuthProviderFailure("provider_error") + + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: + self.userinfo_calls += 1 + raise AssertionError("userinfo must not be called after a token-exchange failure") + + +def _contains_any(value: str, markers: tuple[str, ...]) -> bool: + return any(marker in value for marker in markers) + + +@pytest.mark.integration +def test_oauth_route_errors_do_not_echo_callback_or_secret_values( + postgres_service: object, + caplog: pytest.LogCaptureFixture, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + provider = _FailingYandexOAuthProvider() + dependencies = replace( + create_application_dependencies(), + repository=PostgresLegacyStoreRepository(app_runtime.sessions), + identity_repository=PostgresIdentityRepository(app_runtime.sessions), + session_service=PostgresSessionService(app_runtime.sessions), + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + yandex_oauth=YandexOAuthIntegration( + config=config, + transactions=PostgresOAuthTransactionRepository(app_runtime.sessions), + identities=PostgresExternalIdentityRepository(app_runtime.sessions), + provider=provider, + ), + ) + client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") + login = client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic OAuth error owner"}, + ) + assert login.status_code == 200 + start = client.get("/api/v1/integrations/yandex/start", follow_redirects=False) + assert start.status_code == 302 + state = parse_qs(urlsplit(start.headers["location"]).query, strict_parsing=True)[ + "state" + ][0] + code = secrets.token_urlsafe(32) + caplog.set_level(logging.INFO, logger="directpilot.request") + provider_failure = client.get( + "/api/v1/integrations/yandex/callback", + params={"code": code, "state": state}, + follow_redirects=False, + ) + malformed = client.get( + "/api/v1/integrations/yandex/callback", + params={"state": secrets.token_urlsafe(32)}, + follow_redirects=False, + ) + open_redirect = client.get( + "/api/v1/integrations/yandex/start", + params={"return_path": "https://attacker.invalid/"}, + follow_redirects=False, + ) + + assert provider_failure.status_code == 502 + assert malformed.status_code == 400 + assert open_redirect.status_code == 400 + assert provider.exchange_calls == 1 + assert provider.userinfo_calls == 0 + sensitive_markers = (code, state, config.client_secret) + assert not _contains_any(provider_failure.text, sensitive_markers) + assert not _contains_any(malformed.text, sensitive_markers) + assert not _contains_any(open_redirect.text, sensitive_markers) + log_output = "\n".join(str(record.__dict__) for record in caplog.records) + assert not _contains_any(log_output, sensitive_markers) + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/test_yandex_oauth_provider.py b/tests/test_yandex_oauth_provider.py new file mode 100644 index 0000000..5be5adb --- /dev/null +++ b/tests/test_yandex_oauth_provider.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import secrets +from urllib.parse import parse_qs + +import httpx +import pytest + +from app.modules.integrations.yandex.oauth import YandexOAuthConfiguration +from app.modules.integrations.yandex.provider import ( + HttpxYandexOAuthProvider, + YandexOAuthProviderFailure, +) + + +def test_provider_uses_basic_form_pkce_and_header_userinfo_without_network() -> None: + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + code = secrets.token_urlsafe(32) + verifier = secrets.token_urlsafe(32) + access_token = secrets.token_urlsafe(32) + refresh_token = secrets.token_urlsafe(32) + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "oauth.yandex.ru": + form = parse_qs(request.content.decode("ascii"), strict_parsing=True) + assert request.method == "POST" + assert request.url.path == "/token" + assert request.headers["content-type"].startswith( + "application/x-www-form-urlencoded" + ) + assert request.headers["authorization"].startswith("Basic ") + assert set(form) == {"grant_type", "code", "code_verifier"} + return httpx.Response( + 200, + json={ + "token_type": "bearer", + "access_token": access_token, + "expires_in": 3600, + "refresh_token": refresh_token, + }, + ) + assert request.method == "GET" + assert request.url.host == "login.yandex.ru" + assert request.url.path == "/info" + assert dict(request.url.params) == {"format": "json"} + assert request.headers["authorization"].startswith("OAuth ") + return httpx.Response( + 200, + json={ + "id": "stable-yandex-user-id", + "client_id": config.client_id, + "login": "synthetic-login", + "display_name": "Synthetic display name", + }, + ) + + provider = HttpxYandexOAuthProvider( + config=config, + transport=httpx.MockTransport(handler), + ) + + tokens = provider.exchange_code(code=code, code_verifier=verifier) + userinfo = provider.fetch_user_info(access_token=tokens.access_token) + + assert len(requests) == 2 + assert tokens.expires_in == 3600 + assert userinfo.subject == "stable-yandex-user-id" + assert userinfo.login == "synthetic-login" + assert "access_token=" not in repr(tokens) + assert "refresh_token=" not in repr(tokens) + + +def _contains_any(value: str, markers: tuple[str, ...]) -> bool: + return any(marker in value for marker in markers) + + +def test_provider_maps_raw_error_body_to_a_safe_failure() -> None: + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + raw_body_marker = secrets.token_urlsafe(32) + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={ + "error": "invalid_grant", + "error_description": raw_body_marker, + }, + ) + + provider = HttpxYandexOAuthProvider( + config=config, + transport=httpx.MockTransport(handler), + ) + with pytest.raises(YandexOAuthProviderFailure) as captured: + provider.exchange_code( + code=secrets.token_urlsafe(32), + code_verifier=secrets.token_urlsafe(32), + ) + + assert captured.value.kind == "invalid_grant" + assert not _contains_any( + f"{captured.value!s}\n{captured.value!r}", + (raw_body_marker, config.client_secret), + ) diff --git a/tests/test_yandex_oauth_service.py b/tests/test_yandex_oauth_service.py new file mode 100644 index 0000000..263c3be --- /dev/null +++ b/tests/test_yandex_oauth_service.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +from datetime import datetime, timedelta, timezone +from urllib.parse import parse_qs, urlsplit +from uuid import UUID, uuid4 + +from app.modules.integrations.yandex.oauth import ( + ConsumedOAuthTransaction, + NewOAuthTransaction, + OAuthCallbackService, + OAuthStartService, + YandexOAuthConfiguration, + YandexOAuthTokenSet, + YandexUserInfo, + hash_oauth_state, +) + + +class _RecordingOAuthTransactions: + def __init__(self) -> None: + self.created: NewOAuthTransaction | None = None + + def create(self, transaction: NewOAuthTransaction) -> None: + self.created = transaction + + +def test_start_builds_the_exact_yandex_authorize_url_with_s256_pkce() -> None: + now = datetime(2026, 9, 5, tzinfo=timezone.utc) + transactions = _RecordingOAuthTransactions() + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + service = OAuthStartService(config=config, transactions=transactions) + + started = service.start( + user_id=uuid4(), + workspace_id=uuid4(), + browser_session_id=uuid4(), + return_path="/", + now=now, + ) + + transaction = transactions.created + assert transaction is not None + query = parse_qs(urlsplit(started.authorization_url).query, strict_parsing=True) + assert urlsplit(started.authorization_url).scheme == "https" + assert urlsplit(started.authorization_url).netloc == "oauth.yandex.ru" + assert urlsplit(started.authorization_url).path == "/authorize" + assert set(query) == { + "response_type", + "client_id", + "redirect_uri", + "state", + "code_challenge", + "code_challenge_method", + } + assert query["response_type"] == ["code"] + assert query["client_id"] == [config.client_id] + assert query["redirect_uri"] == [config.redirect_uri] + assert query["code_challenge_method"] == ["S256"] + assert len(query["state"][0]) >= 43 + assert hmac.compare_digest(transaction.state_hash, hash_oauth_state(query["state"][0])) + assert transaction.expires_at == now + timedelta(minutes=10) + expected_challenge = base64.urlsafe_b64encode( + hashlib.sha256(transaction.code_verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + assert hmac.compare_digest(query["code_challenge"][0], expected_challenge) + assert "code_verifier=" not in repr(transaction) + + +class _ConsumedOAuthTransactions: + def __init__(self, transaction: ConsumedOAuthTransaction) -> None: + self._transaction = transaction + self.calls = 0 + + def consume(self, **_: object) -> ConsumedOAuthTransaction | None: + self.calls += 1 + return self._transaction + + +class _RecordingIdentities: + def __init__(self) -> None: + self.user_id: UUID | None = None + self.workspace_id: UUID | None = None + self.subject: str | None = None + + def bind_yandex_identity( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + profile_login: str | None, + profile_display_name: str | None, + ) -> None: + assert issuer == "https://login.yandex.ru" + assert profile_login == "synthetic-login" + assert profile_display_name == "Synthetic display name" + self.user_id = user_id + self.workspace_id = workspace_id + self.subject = subject + + +class _MockYandexOAuthProvider: + def __init__(self, config: YandexOAuthConfiguration) -> None: + self._config = config + self.code: str | None = None + self.verifier: str | None = None + self.userinfo_calls = 0 + + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: + self.code = code + self.verifier = code_verifier + return YandexOAuthTokenSet( + access_token=secrets.token_urlsafe(32), + refresh_token=secrets.token_urlsafe(32), + expires_in=3600, + ) + + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: + assert len(access_token) >= 43 + self.userinfo_calls += 1 + return YandexUserInfo( + subject="stable-yandex-user-id", + client_id=self._config.client_id, + login="synthetic-login", + display_name="Synthetic display name", + ) + + +def test_callback_binds_the_stable_yandex_subject_to_the_authenticated_local_user() -> None: + now = datetime(2026, 9, 5, tzinfo=timezone.utc) + user_id = uuid4() + workspace_id = uuid4() + browser_session_id = uuid4() + code = secrets.token_urlsafe(32) + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + transactions = _ConsumedOAuthTransactions( + ConsumedOAuthTransaction( + id=uuid4(), + code_verifier=secrets.token_urlsafe(32), + user_id=user_id, + workspace_id=workspace_id, + browser_session_id=browser_session_id, + return_path="/", + expires_at=now + timedelta(minutes=5), + consumed_at=now, + ) + ) + identities = _RecordingIdentities() + provider = _MockYandexOAuthProvider(config) + service = OAuthCallbackService( + config=config, + transactions=transactions, + identities=identities, + provider=provider, + ) + + completed = service.complete( + code=code, + state=secrets.token_urlsafe(32), + user_id=user_id, + workspace_id=workspace_id, + browser_session_id=browser_session_id, + ) + + assert transactions.calls == 1 + assert provider.code is not None and hmac.compare_digest(provider.code, code) + assert provider.verifier is not None + assert identities.user_id == user_id + assert identities.workspace_id == workspace_id + assert identities.subject == "stable-yandex-user-id" + assert provider.userinfo_calls == 1 + assert completed.return_path == "/" From 6916ddd8f405853cef8eec42cdd829724e55134f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 02:25:47 +0300 Subject: [PATCH 2/8] feat: add encrypted Yandex credential vault --- app/bootstrap/dependencies.py | 28 +- app/config.py | 7 + ...260906_0008_yandex_provider_connections.py | 141 ++++++ app/db/models.py | 41 ++ .../integrations/yandex/credentials.py | 474 ++++++++++++++++++ app/modules/integrations/yandex/oauth.py | 86 +++- app/modules/integrations/yandex/repository.py | 206 +++++++- app/modules/integrations/yandex/router.py | 18 +- pyproject.toml | 1 + tests/integration/__init__.py | 1 + tests/integration/test_yandex_credentials.py | 278 ++++++++++ tests/integration/test_yandex_oauth_http.py | 179 ++++++- tests/test_yandex_credentials.py | 184 +++++++ tests/test_yandex_oauth_service.py | 31 ++ uv.lock | 165 ++++++ 15 files changed, 1819 insertions(+), 21 deletions(-) create mode 100644 app/db/migrations/versions/20260906_0008_yandex_provider_connections.py create mode 100644 app/modules/integrations/yandex/credentials.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_yandex_credentials.py create mode 100644 tests/test_yandex_credentials.py diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py index ad3f8f2..25c49d5 100644 --- a/app/bootstrap/dependencies.py +++ b/app/bootstrap/dependencies.py @@ -15,6 +15,11 @@ ) from app.db.schema import check_schema_compatibility from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.credentials import ( + CredentialConfigurationError, + CredentialKeyRing, + CredentialVault, +) from app.modules.integrations.yandex.oauth import ( YandexOAuthConfiguration, YandexOAuthIntegration, @@ -22,6 +27,7 @@ from app.modules.integrations.yandex.repository import ( PostgresExternalIdentityRepository, PostgresOAuthTransactionRepository, + PostgresYandexProviderConnectionRepository, ) from app.modules.sessions.service import PostgresSessionService from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer @@ -68,8 +74,7 @@ def _fake_auth_is_enabled(app_env: str) -> bool: ).lower() == "1" -def _configured_yandex_oauth() -> YandexOAuthConfiguration | None: - settings = get_settings() +def _configured_yandex_oauth(settings: Settings) -> YandexOAuthConfiguration | None: if settings.yandex_client_id is None or settings.yandex_client_secret is None: return None return YandexOAuthConfiguration( @@ -79,15 +84,31 @@ def _configured_yandex_oauth() -> YandexOAuthConfiguration | None: ) +def _configured_credential_vault(settings: Settings) -> CredentialVault: + secret_file = settings.credential_keyring_secret_file + if not secret_file: + raise CredentialConfigurationError("Credential key-ring secret file is not configured") + return CredentialVault(CredentialKeyRing.from_json_secret_file(secret_file)) + + def create_application_dependencies() -> ApplicationDependencies: database_url = os.environ.get(DATABASE_URL_ENV) app_env = os.environ.get("DIRECTPILOT_APP_ENV", "local").lower() if database_url: - oauth_config = _configured_yandex_oauth() + settings = get_settings() + oauth_config = _configured_yandex_oauth(settings) runtime = create_database_runtime( DatabaseSettings.from_mapping({DATABASE_URL_ENV: database_url}) ) try: + credential_persister = ( + PostgresYandexProviderConnectionRepository( + runtime.sessions, + vault=_configured_credential_vault(settings), + ) + if oauth_config is not None + else None + ) check_schema_compatibility(runtime) except Exception: runtime.close() @@ -105,6 +126,7 @@ def create_application_dependencies() -> ApplicationDependencies: transactions=PostgresOAuthTransactionRepository(runtime.sessions), identities=PostgresExternalIdentityRepository(runtime.sessions), config=oauth_config, + credential_persister=credential_persister, ), fake_auth_enabled=_fake_auth_is_enabled(app_env), ) diff --git a/app/config.py b/app/config.py index c213a37..84c5973 100644 --- a/app/config.py +++ b/app/config.py @@ -39,6 +39,13 @@ class Settings(BaseSettings): yandex_oauth_token: str | None = None yandex_redirect_uri: str = "https://oauth.yandex.ru/verification_code" yandex_oauth_redirect_uri: str = "http://127.0.0.1:8000/api/v1/integrations/yandex/callback" + credential_keyring_secret_file: str | None = Field( + default=None, + validation_alias=AliasChoices( + "DIRECTPILOT_CREDENTIAL_KEYRING_SECRET_FILE", + "credential_keyring_secret_file", + ), + ) # Yandex AI Studio / Search API v2 — used by the modern Wordstat client. # Optional folderId is the cloud folder that owns the service account diff --git a/app/db/migrations/versions/20260906_0008_yandex_provider_connections.py b/app/db/migrations/versions/20260906_0008_yandex_provider_connections.py new file mode 100644 index 0000000..48cb731 --- /dev/null +++ b/app/db/migrations/versions/20260906_0008_yandex_provider_connections.py @@ -0,0 +1,141 @@ +"""P4 encrypted workspace-scoped Yandex provider connections.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260906_0008" +down_revision = "20260905_0007" +branch_labels = None +depends_on = None + +_WORKSPACE_SETTING = "NULLIF(current_setting('app.current_workspace_id', true), '')::uuid" +_USER_SETTING = "NULLIF(current_setting('app.current_user_id', true), '')::uuid" +_MEMBERSHIP_CHECK = f"public.directpilot_has_active_membership({_WORKSPACE_SETTING}, {_USER_SETTING})" + + +def _enable_provider_connection_rls() -> None: + identity_owner_check = ( + "EXISTS (SELECT 1 FROM external_identities " + "WHERE external_identities.id = yandex_provider_connections.external_identity_id " + f"AND external_identities.user_id = {_USER_SETTING})" + ) + predicate = ( + f"workspace_id = {_WORKSPACE_SETTING} AND {_MEMBERSHIP_CHECK} " + f"AND {identity_owner_check}" + ) + op.execute("ALTER TABLE yandex_provider_connections ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE yandex_provider_connections FORCE ROW LEVEL SECURITY") + op.execute( + "CREATE POLICY yandex_provider_connections_workspace_isolation " + "ON yandex_provider_connections FOR ALL TO directpilot_app " + f"USING ({predicate}) WITH CHECK ({predicate})" + ) + + +def upgrade() -> None: + op.create_table( + "yandex_provider_connections", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("external_identity_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("token_ciphertext", sa.LargeBinary(), nullable=False), + sa.Column("token_nonce", sa.LargeBinary(length=12), nullable=False), + sa.Column("wrapped_dek", sa.LargeBinary(), nullable=False), + sa.Column("wrap_nonce", sa.LargeBinary(length=12), nullable=False), + sa.Column("kek_key_id", sa.String(length=128), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("credential_updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.CheckConstraint( + "provider = 'yandex'", + name=op.f("ck_yandex_provider_connections_provider_yandex"), + ), + sa.CheckConstraint( + "schema_version > 0", + name=op.f("ck_yandex_provider_connections_schema_version_positive"), + ), + sa.CheckConstraint( + "version > 0", + name=op.f("ck_yandex_provider_connections_version_positive"), + ), + sa.CheckConstraint( + "octet_length(token_nonce) = 12", + name=op.f("ck_yandex_provider_connections_token_nonce_length"), + ), + sa.CheckConstraint( + "octet_length(wrap_nonce) = 12", + name=op.f("ck_yandex_provider_connections_wrap_nonce_length"), + ), + sa.CheckConstraint( + "char_length(kek_key_id) > 0", + name=op.f("ck_yandex_provider_connections_kek_key_id_not_empty"), + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.id"], + name=op.f("fk_yandex_provider_connections_workspace_id_workspaces"), + ), + sa.ForeignKeyConstraint( + ["external_identity_id"], + ["external_identities.id"], + name=op.f( + "fk_yandex_provider_connections_external_identity_id_external_identities" + ), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_yandex_provider_connections")), + sa.UniqueConstraint( + "workspace_id", + "provider", + "external_identity_id", + name=op.f("uq_yandex_provider_connections_workspace_id"), + ), + ) + op.create_index( + op.f("ix_yandex_provider_connections_workspace_id"), + "yandex_provider_connections", + ["workspace_id"], + ) + op.create_index( + op.f("ix_yandex_provider_connections_external_identity_id"), + "yandex_provider_connections", + ["external_identity_id"], + ) + op.create_index( + op.f("ix_yandex_provider_connections_status"), + "yandex_provider_connections", + ["status"], + ) + op.execute("REVOKE ALL ON TABLE yandex_provider_connections FROM PUBLIC") + op.execute("GRANT SELECT, INSERT, UPDATE ON TABLE yandex_provider_connections TO directpilot_app") + _enable_provider_connection_rls() + + +def downgrade() -> None: + op.execute( + "DROP POLICY IF EXISTS yandex_provider_connections_workspace_isolation " + "ON yandex_provider_connections" + ) + op.execute("ALTER TABLE yandex_provider_connections NO FORCE ROW LEVEL SECURITY") + op.execute("ALTER TABLE yandex_provider_connections DISABLE ROW LEVEL SECURITY") + op.drop_index( + op.f("ix_yandex_provider_connections_status"), + table_name="yandex_provider_connections", + ) + op.drop_index( + op.f("ix_yandex_provider_connections_external_identity_id"), + table_name="yandex_provider_connections", + ) + op.drop_index( + op.f("ix_yandex_provider_connections_workspace_id"), + table_name="yandex_provider_connections", + ) + op.drop_table("yandex_provider_connections") diff --git a/app/db/models.py b/app/db/models.py index ee80044..444c33b 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -6,8 +6,11 @@ from sqlalchemy import ( Boolean, + CheckConstraint, DateTime, ForeignKey, + Integer, + LargeBinary, MetaData, String, UniqueConstraint, @@ -199,6 +202,44 @@ class ExternalIdentityRecord(Base): ) +class YandexProviderConnectionRecord(Base): + __tablename__ = "yandex_provider_connections" + __table_args__ = ( + UniqueConstraint("workspace_id", "provider", "external_identity_id"), + CheckConstraint("provider = 'yandex'", name="provider_yandex"), + CheckConstraint("schema_version > 0", name="schema_version_positive"), + CheckConstraint("version > 0", name="version_positive"), + ) + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + workspace_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + nullable=False, + ) + external_identity_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("external_identities.id"), + nullable=False, + ) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + token_ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + token_nonce: Mapped[bytes] = mapped_column(LargeBinary(12), nullable=False) + wrapped_dek: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + wrap_nonce: Mapped[bytes] = mapped_column(LargeBinary(12), nullable=False) + kek_key_id: Mapped[str] = mapped_column(String(128), nullable=False) + schema_version: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + access_token_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + refresh_token_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + credential_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False) + + class AuditEventRecord(Base): __tablename__ = "audit_events" diff --git a/app/modules/integrations/yandex/credentials.py b/app/modules/integrations/yandex/credentials.py new file mode 100644 index 0000000..f3daf58 --- /dev/null +++ b/app/modules/integrations/yandex/credentials.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import base64 +import json +import os +import re +import stat +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from pathlib import Path +from types import MappingProxyType +from typing import Mapping +from uuid import UUID + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +CREDENTIAL_SCHEMA_VERSION = 1 +YANDEX_PROVIDER = "yandex" +_TOKEN_PAYLOAD_PURPOSE = "yandex-token-payload" +_DEK_WRAP_PURPOSE = "yandex-dek-wrap" +_NONCE_LENGTH = 12 +_DEK_LENGTH = 32 +_KEY_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +_BASE64URL_KEY_PATTERN = re.compile(r"[A-Za-z0-9_-]{43}=?\Z") + + +class CredentialVaultError(RuntimeError): + """Base class for safe credential-vault failures.""" + + +class CredentialConfigurationError(CredentialVaultError): + """Raised for an invalid external credential-key configuration.""" + + +class CredentialKeyUnavailable(CredentialVaultError): + """Raised when a configured or historical KEK cannot be used.""" + + +class CredentialEncryptionError(CredentialVaultError): + """Raised when credentials cannot be encrypted safely.""" + + +class CredentialDecryptionError(CredentialVaultError): + """Raised when an encrypted credential cannot be authenticated or decoded.""" + + +class CredentialPayloadError(CredentialVaultError): + """Raised when a credential payload cannot satisfy the bounded schema.""" + + +@dataclass(frozen=True, slots=True) +class CredentialContext: + """Non-secret identifiers bound into both AES-GCM authenticated-data layers.""" + + workspace_id: UUID + connection_id: UUID + schema_version: int = CREDENTIAL_SCHEMA_VERSION + provider: str = YANDEX_PROVIDER + token_purpose: str = _TOKEN_PAYLOAD_PURPOSE + wrap_purpose: str = _DEK_WRAP_PURPOSE + + @classmethod + def for_yandex_connection( + cls, + *, + workspace_id: UUID, + connection_id: UUID, + ) -> CredentialContext: + return cls(workspace_id=workspace_id, connection_id=connection_id) + + def __post_init__(self) -> None: + if self.schema_version <= 0: + raise CredentialConfigurationError("Credential context is invalid") + if not self.provider or not self.provider.isascii() or len(self.provider) > 32: + raise CredentialConfigurationError("Credential context is invalid") + for purpose in (self.token_purpose, self.wrap_purpose): + if not purpose or not purpose.isascii() or len(purpose) > 128: + raise CredentialConfigurationError("Credential context is invalid") + + +@dataclass(frozen=True, slots=True) +class YandexCredentialPayload: + """Opaque plaintext token value held only in the bounded provider-call scope.""" + + access_token: str = field(repr=False) + refresh_token: str = field(repr=False) + access_token_expires_at: datetime + refresh_token_expires_at: datetime | None = None + + def __post_init__(self) -> None: + _validate_token_value(self.access_token) + _validate_token_value(self.refresh_token) + object.__setattr__( + self, + "access_token_expires_at", + _normalized_timestamp(self.access_token_expires_at), + ) + if self.refresh_token_expires_at is not None: + object.__setattr__( + self, + "refresh_token_expires_at", + _normalized_timestamp(self.refresh_token_expires_at), + ) + + +@dataclass(frozen=True, slots=True) +class EncryptedYandexCredential: + """Persistable envelope fields; repr intentionally excludes encrypted material too.""" + + token_ciphertext: bytes = field(repr=False) + token_nonce: bytes = field(repr=False) + wrapped_dek: bytes = field(repr=False) + wrap_nonce: bytes = field(repr=False) + kek_key_id: str + schema_version: int + access_token_expires_at: datetime + refresh_token_expires_at: datetime | None = None + + def __post_init__(self) -> None: + if self.schema_version <= 0 or not _is_valid_key_id(self.kek_key_id): + raise CredentialDecryptionError("Encrypted credential metadata is invalid") + if not isinstance(self.token_ciphertext, bytes) or not self.token_ciphertext: + raise CredentialDecryptionError("Encrypted credential metadata is invalid") + if not isinstance(self.wrapped_dek, bytes) or not self.wrapped_dek: + raise CredentialDecryptionError("Encrypted credential metadata is invalid") + if not isinstance(self.token_nonce, bytes) or len(self.token_nonce) != _NONCE_LENGTH: + raise CredentialDecryptionError("Encrypted credential metadata is invalid") + if not isinstance(self.wrap_nonce, bytes) or len(self.wrap_nonce) != _NONCE_LENGTH: + raise CredentialDecryptionError("Encrypted credential metadata is invalid") + object.__setattr__( + self, + "access_token_expires_at", + _normalized_timestamp(self.access_token_expires_at), + ) + if self.refresh_token_expires_at is not None: + object.__setattr__( + self, + "refresh_token_expires_at", + _normalized_timestamp(self.refresh_token_expires_at), + ) + + +@dataclass(frozen=True, slots=True) +class CredentialKeyRing: + """Versioned KEKs injected from a secret mount, never persisted or repr'd.""" + + active_key_id: str + keys: Mapping[str, bytes] = field(repr=False) + + def __post_init__(self) -> None: + if not _is_valid_key_id(self.active_key_id): + raise CredentialConfigurationError("Credential key ring is invalid") + normalized: dict[str, bytes] = {} + for key_id, key_material in self.keys.items(): + if not _is_valid_key_id(key_id) or not isinstance(key_material, bytes): + raise CredentialConfigurationError("Credential key ring is invalid") + if len(key_material) != _DEK_LENGTH: + raise CredentialConfigurationError("Credential key ring is invalid") + normalized[key_id] = bytes(key_material) + if not normalized or self.active_key_id not in normalized: + raise CredentialConfigurationError("Credential key ring is invalid") + object.__setattr__(self, "keys", MappingProxyType(normalized)) + + @classmethod + def from_keys( + cls, + *, + active_key_id: str, + keys: Mapping[str, bytes], + ) -> CredentialKeyRing: + """Construct an injectable in-memory key ring for tests and bootstrapping.""" + return cls(active_key_id=active_key_id, keys=keys) + + @classmethod + def from_json_secret_file(cls, path: str | Path) -> CredentialKeyRing: + """Load base64url 256-bit KEKs from a mode-restricted JSON secret file.""" + secret_path = Path(path) + try: + file_status = secret_path.stat() + if not stat.S_ISREG(file_status.st_mode): + raise CredentialConfigurationError("Credential key-ring secret file is invalid") + if os.name == "posix" and stat.S_IMODE(file_status.st_mode) & 0o077: + raise CredentialConfigurationError("Credential key-ring secret file permissions are unsafe") + serialized = secret_path.read_text(encoding="utf-8") + except CredentialConfigurationError: + raise + except (OSError, UnicodeDecodeError): + raise CredentialConfigurationError("Credential key-ring secret file is unavailable") from None + try: + document = json.loads(serialized, object_pairs_hook=_json_object_without_duplicates) + except (json.JSONDecodeError, _DuplicateJsonKey): + raise CredentialConfigurationError("Credential key-ring secret file is invalid") from None + if not isinstance(document, dict) or set(document) != {"active_key_id", "keys"}: + raise CredentialConfigurationError("Credential key-ring secret file is invalid") + active_key_id = document["active_key_id"] + encoded_keys = document["keys"] + if not isinstance(active_key_id, str) or not isinstance(encoded_keys, dict): + raise CredentialConfigurationError("Credential key-ring secret file is invalid") + decoded_keys: dict[str, bytes] = {} + for key_id, encoded_key in encoded_keys.items(): + if not isinstance(key_id, str) or not isinstance(encoded_key, str): + raise CredentialConfigurationError("Credential key-ring secret file is invalid") + decoded_keys[key_id] = _decode_base64url_key(encoded_key) + return cls.from_keys(active_key_id=active_key_id, keys=decoded_keys) + + def active_key(self) -> tuple[str, bytes]: + return self.active_key_id, self.keys[self.active_key_id] + + def key_for(self, key_id: str) -> bytes: + try: + return self.keys[key_id] + except KeyError: + raise CredentialKeyUnavailable("Credential key is unavailable") from None + + +class CredentialVault: + """AES-256-GCM envelope encryption for one Yandex connection credential payload.""" + + def __init__(self, key_ring: CredentialKeyRing) -> None: + self._key_ring = key_ring + + def encrypt( + self, + *, + context: CredentialContext, + payload: YandexCredentialPayload, + ) -> EncryptedYandexCredential: + if context.schema_version != CREDENTIAL_SCHEMA_VERSION or context.provider != YANDEX_PROVIDER: + raise CredentialEncryptionError("Credential encryption context is invalid") + plaintext = _serialize_payload(payload) + dek = os.urandom(_DEK_LENGTH) + try: + token_nonce = os.urandom(_NONCE_LENGTH) + wrap_nonce = os.urandom(_NONCE_LENGTH) + key_id, kek = self._key_ring.active_key() + token_ciphertext = AESGCM(dek).encrypt( + token_nonce, + plaintext, + _authenticated_data(context, context.token_purpose), + ) + wrapped_dek = AESGCM(kek).encrypt( + wrap_nonce, + dek, + _authenticated_data(context, context.wrap_purpose, kek_key_id=key_id), + ) + return EncryptedYandexCredential( + token_ciphertext=token_ciphertext, + token_nonce=token_nonce, + wrapped_dek=wrapped_dek, + wrap_nonce=wrap_nonce, + kek_key_id=key_id, + schema_version=context.schema_version, + access_token_expires_at=payload.access_token_expires_at, + refresh_token_expires_at=payload.refresh_token_expires_at, + ) + except (CredentialVaultError, ValueError): + raise + except Exception: + raise CredentialEncryptionError("Credential encryption failed") from None + finally: + del plaintext + del dek + + def decrypt( + self, + *, + context: CredentialContext, + encrypted: EncryptedYandexCredential, + ) -> YandexCredentialPayload: + dek = self._unwrap_dek(context=context, encrypted=encrypted) + try: + plaintext = AESGCM(dek).decrypt( + encrypted.token_nonce, + encrypted.token_ciphertext, + _authenticated_data(context, context.token_purpose), + ) + payload = _deserialize_payload(plaintext) + if ( + payload.access_token_expires_at != encrypted.access_token_expires_at + or payload.refresh_token_expires_at != encrypted.refresh_token_expires_at + ): + raise CredentialDecryptionError("Encrypted credential metadata is invalid") + return payload + except CredentialVaultError: + raise + except (InvalidTag, ValueError, UnicodeDecodeError, json.JSONDecodeError): + raise CredentialDecryptionError("Credential decryption failed") from None + finally: + del dek + + def rewrap_to_active_key( + self, + *, + context: CredentialContext, + encrypted: EncryptedYandexCredential, + ) -> EncryptedYandexCredential: + """Rotate only the wrapped DEK layer; token ciphertext and nonce stay byte-identical.""" + dek = self._unwrap_dek(context=context, encrypted=encrypted) + try: + active_key_id, active_kek = self._key_ring.active_key() + wrap_nonce = os.urandom(_NONCE_LENGTH) + wrapped_dek = AESGCM(active_kek).encrypt( + wrap_nonce, + dek, + _authenticated_data(context, context.wrap_purpose, kek_key_id=active_key_id), + ) + return replace( + encrypted, + wrapped_dek=wrapped_dek, + wrap_nonce=wrap_nonce, + kek_key_id=active_key_id, + ) + except CredentialVaultError: + raise + except Exception: + raise CredentialEncryptionError("Credential rewrap failed") from None + finally: + del dek + + def _unwrap_dek( + self, + *, + context: CredentialContext, + encrypted: EncryptedYandexCredential, + ) -> bytes: + if ( + context.schema_version != CREDENTIAL_SCHEMA_VERSION + or context.provider != YANDEX_PROVIDER + or encrypted.schema_version != context.schema_version + ): + raise CredentialDecryptionError("Encrypted credential metadata is invalid") + kek = self._key_ring.key_for(encrypted.kek_key_id) + try: + dek = AESGCM(kek).decrypt( + encrypted.wrap_nonce, + encrypted.wrapped_dek, + _authenticated_data( + context, + context.wrap_purpose, + kek_key_id=encrypted.kek_key_id, + ), + ) + except (InvalidTag, ValueError): + raise CredentialDecryptionError("Credential decryption failed") from None + if len(dek) != _DEK_LENGTH: + raise CredentialDecryptionError("Credential decryption failed") + return dek + + +class _DuplicateJsonKey(ValueError): + pass + + +def _json_object_without_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]: + document: dict[str, object] = {} + for key, value in pairs: + if key in document: + raise _DuplicateJsonKey + document[key] = value + return document + + +def _decode_base64url_key(value: str) -> bytes: + if not _BASE64URL_KEY_PATTERN.fullmatch(value): + raise CredentialConfigurationError("Credential key-ring secret file is invalid") + try: + decoded = base64.b64decode( + value + ("=" * (-len(value) % 4)), + altchars=b"-_", + validate=True, + ) + except ValueError: + raise CredentialConfigurationError("Credential key-ring secret file is invalid") from None + if len(decoded) != _DEK_LENGTH: + raise CredentialConfigurationError("Credential key-ring secret file is invalid") + return decoded + + +def _is_valid_key_id(value: str) -> bool: + return bool(_KEY_ID_PATTERN.fullmatch(value)) + + +def _validate_token_value(value: str) -> None: + if not isinstance(value, str) or not value or len(value) > 16_384: + raise CredentialPayloadError("Credential payload is invalid") + + +def _normalized_timestamp(value: datetime) -> datetime: + if not isinstance(value, datetime) or value.tzinfo is None: + raise CredentialPayloadError("Credential payload is invalid") + return value.astimezone(timezone.utc) + + +def _datetime_to_payload_value(value: datetime | None) -> str | None: + if value is None: + return None + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _datetime_from_payload_value(value: object) -> datetime | None: + if value is None: + return None + if not isinstance(value, str) or not value.endswith("Z"): + raise CredentialDecryptionError("Credential payload is invalid") + try: + parsed = datetime.fromisoformat(f"{value[:-1]}+00:00") + except ValueError: + raise CredentialDecryptionError("Credential payload is invalid") from None + return _normalized_timestamp(parsed) + + +def _serialize_payload(payload: YandexCredentialPayload) -> bytes: + document = { + "access_token": payload.access_token, + "access_token_expires_at": _datetime_to_payload_value(payload.access_token_expires_at), + "refresh_token": payload.refresh_token, + "refresh_token_expires_at": _datetime_to_payload_value(payload.refresh_token_expires_at), + "schema_version": CREDENTIAL_SCHEMA_VERSION, + } + return json.dumps(document, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def _deserialize_payload(serialized: bytes) -> YandexCredentialPayload: + try: + document = json.loads(serialized.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise CredentialDecryptionError("Credential payload is invalid") from None + if not isinstance(document, dict) or set(document) != { + "access_token", + "access_token_expires_at", + "refresh_token", + "refresh_token_expires_at", + "schema_version", + }: + raise CredentialDecryptionError("Credential payload is invalid") + if document["schema_version"] != CREDENTIAL_SCHEMA_VERSION: + raise CredentialDecryptionError("Credential payload is invalid") + try: + return YandexCredentialPayload( + access_token=document["access_token"], + refresh_token=document["refresh_token"], + access_token_expires_at=_required_timestamp(document["access_token_expires_at"]), + refresh_token_expires_at=_datetime_from_payload_value( + document["refresh_token_expires_at"] + ), + ) + except CredentialPayloadError: + raise CredentialDecryptionError("Credential payload is invalid") from None + + +def _required_timestamp(value: object) -> datetime: + parsed = _datetime_from_payload_value(value) + if parsed is None: + raise CredentialDecryptionError("Credential payload is invalid") + return parsed + + +def _authenticated_data( + context: CredentialContext, + purpose: str, + *, + kek_key_id: str | None = None, +) -> bytes: + document: dict[str, int | str] = { + "connection_id": str(context.connection_id), + "provider": context.provider, + "purpose": purpose, + "schema_version": context.schema_version, + "workspace_id": str(context.workspace_id), + } + if kek_key_id is not None: + document["kek_key_id"] = kek_key_id + return json.dumps(document, separators=(",", ":"), sort_keys=True).encode("ascii") diff --git a/app/modules/integrations/yandex/oauth.py b/app/modules/integrations/yandex/oauth.py index 0be90a3..04edfbf 100644 --- a/app/modules/integrations/yandex/oauth.py +++ b/app/modules/integrations/yandex/oauth.py @@ -10,6 +10,11 @@ from urllib.parse import urlencode from uuid import UUID +from app.modules.integrations.yandex.credentials import ( + CredentialPayloadError, + YandexCredentialPayload, +) + YANDEX_AUTHORIZE_URL = "https://oauth.yandex.ru/authorize" LOCAL_YANDEX_CALLBACK_URI = "http://127.0.0.1:8000/api/v1/integrations/yandex/callback" MAX_OAUTH_TRANSACTION_TTL = timedelta(minutes=10) @@ -94,6 +99,18 @@ def bind_yandex_identity( ) -> object: ... +class OAuthCredentialPersister(Protocol): + def persist_yandex_oauth_tokens( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + payload: YandexCredentialPayload, + ) -> object: ... + + @dataclass(frozen=True, slots=True) class YandexOAuthTokenSet: access_token: str = field(repr=False) @@ -132,6 +149,7 @@ class YandexOAuthIntegration: identities: ExternalIdentityBinder config: YandexOAuthConfiguration | None = None provider: YandexOAuthProvider | None = None + credential_persister: OAuthCredentialPersister | None = None class OAuthCallbackRejected(RuntimeError): @@ -142,6 +160,10 @@ class OAuthClientMismatch(RuntimeError): """Raised when userinfo does not belong to the configured OAuth application.""" +class OAuthCredentialPersistenceUnavailable(RuntimeError): + """Raised when OAuth callback vault persistence is not safely configured.""" + + @dataclass(frozen=True, slots=True) class OAuthCallbackResult: return_path: str @@ -228,11 +250,13 @@ def __init__( transactions: OAuthTransactionConsumer, identities: ExternalIdentityBinder, provider: YandexOAuthProvider, + credential_persister: OAuthCredentialPersister | None, ) -> None: self._config = config self._transactions = transactions self._identities = identities self._provider = provider + self._credential_persister = credential_persister def complete( self, @@ -243,6 +267,9 @@ def complete( workspace_id: UUID, browser_session_id: UUID, ) -> OAuthCallbackResult: + credential_persister = self._credential_persister + if credential_persister is None: + raise OAuthCredentialPersistenceUnavailable("OAuth credential vault is unavailable") if not _is_valid_callback_value(code, maximum_length=4096) or not _is_valid_callback_value( state, maximum_length=1024 ): @@ -260,21 +287,52 @@ def complete( code=code, code_verifier=transaction.code_verifier, ) - userinfo = self._provider.fetch_user_info(access_token=tokens.access_token) - if not hmac.compare_digest(userinfo.client_id, self._config.client_id): - raise OAuthClientMismatch("Yandex OAuth application is invalid") - if not _is_valid_callback_value(userinfo.subject, maximum_length=255): - raise OAuthCallbackRejected("Yandex OAuth subject is invalid") - self._identities.bind_yandex_identity( - user_id=transaction.user_id, - workspace_id=transaction.workspace_id, - issuer="https://login.yandex.ru", - subject=userinfo.subject, - profile_login=_bounded_profile_value(userinfo.login), - profile_display_name=_bounded_profile_value(userinfo.display_name), + try: + userinfo = self._provider.fetch_user_info(access_token=tokens.access_token) + if not hmac.compare_digest(userinfo.client_id, self._config.client_id): + raise OAuthClientMismatch("Yandex OAuth application is invalid") + if not _is_valid_callback_value(userinfo.subject, maximum_length=255): + raise OAuthCallbackRejected("Yandex OAuth subject is invalid") + self._identities.bind_yandex_identity( + user_id=transaction.user_id, + workspace_id=transaction.workspace_id, + issuer="https://login.yandex.ru", + subject=userinfo.subject, + profile_login=_bounded_profile_value(userinfo.login), + profile_display_name=_bounded_profile_value(userinfo.display_name), + ) + payload = _credential_payload_from_token_set(tokens) + try: + credential_persister.persist_yandex_oauth_tokens( + user_id=transaction.user_id, + workspace_id=transaction.workspace_id, + issuer="https://login.yandex.ru", + subject=userinfo.subject, + payload=payload, + ) + finally: + del payload + return OAuthCallbackResult(return_path=transaction.return_path) + finally: + del tokens + + +def _credential_payload_from_token_set(tokens: YandexOAuthTokenSet) -> YandexCredentialPayload: + if ( + not isinstance(tokens.expires_in, int) + or isinstance(tokens.expires_in, bool) + or tokens.expires_in <= 0 + ): + raise OAuthCallbackRejected("OAuth token response is invalid") + try: + return YandexCredentialPayload( + access_token=tokens.access_token, + refresh_token=tokens.refresh_token, + access_token_expires_at=_utc_now(None) + timedelta(seconds=tokens.expires_in), + refresh_token_expires_at=None, ) - # P4-01/P4-02 deliberately does not persist the transient token pair; P4-03 owns vaulting. - return OAuthCallbackResult(return_path=transaction.return_path) + except CredentialPayloadError: + raise OAuthCallbackRejected("OAuth token response is invalid") from None def _is_valid_callback_value(value: str, *, maximum_length: int) -> bool: diff --git a/app/modules/integrations/yandex/repository.py b/app/modules/integrations/yandex/repository.py index 4525b99..f0392a5 100644 --- a/app/modules/integrations/yandex/repository.py +++ b/app/modules/integrations/yandex/repository.py @@ -8,8 +8,18 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker -from app.db.models import ExternalIdentityRecord, OAuthTransactionRecord +from app.db.models import ( + ExternalIdentityRecord, + OAuthTransactionRecord, + YandexProviderConnectionRecord, +) from app.db.rls import tenant_transaction +from app.modules.integrations.yandex.credentials import ( + CredentialContext, + CredentialVault, + EncryptedYandexCredential, + YandexCredentialPayload, +) from app.modules.integrations.yandex.oauth import ( ConsumedOAuthTransaction, NewOAuthTransaction, @@ -176,6 +186,200 @@ def bind_yandex_identity( raise ExternalIdentityConflict("external identity is already bound") from None +class ProviderConnectionPersistenceError(RuntimeError): + """Safe failure while persisting or rotating an encrypted provider connection.""" + + +@dataclass(frozen=True, slots=True) +class YandexProviderConnection: + """Safe connection metadata; encrypted fields and tokens are intentionally omitted.""" + + id: UUID + workspace_id: UUID + external_identity_id: UUID + provider: str + status: str + access_token_expires_at: datetime + refresh_token_expires_at: datetime | None + credential_updated_at: datetime + created_at: datetime + updated_at: datetime + version: int + + +class PostgresYandexProviderConnectionRepository: + """Stores encrypted Yandex OAuth credentials under tenant RLS and row locks.""" + + def __init__(self, sessions: sessionmaker[Session], *, vault: CredentialVault) -> None: + self._sessions = sessions + self._vault = vault + + def persist_yandex_oauth_tokens( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + payload: YandexCredentialPayload, + now: datetime | None = None, + ) -> YandexProviderConnection: + observed_at = _utc_now(now) + try: + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + external_identity = session.scalar( + select(ExternalIdentityRecord) + .where( + ExternalIdentityRecord.user_id == user_id, + ExternalIdentityRecord.issuer == issuer, + ExternalIdentityRecord.subject == subject, + ) + .with_for_update() + ) + if external_identity is None: + raise ProviderConnectionPersistenceError( + "Yandex external identity is unavailable" + ) + record = session.scalar( + select(YandexProviderConnectionRecord) + .where( + YandexProviderConnectionRecord.workspace_id == workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + YandexProviderConnectionRecord.external_identity_id == external_identity.id, + ) + .with_for_update() + ) + connection_id = record.id if record is not None else uuid4() + encrypted = self._vault.encrypt( + context=CredentialContext.for_yandex_connection( + workspace_id=workspace_id, + connection_id=connection_id, + ), + payload=payload, + ) + if record is None: + record = YandexProviderConnectionRecord( + id=connection_id, + workspace_id=workspace_id, + external_identity_id=external_identity.id, + provider="yandex", + token_ciphertext=encrypted.token_ciphertext, + token_nonce=encrypted.token_nonce, + wrapped_dek=encrypted.wrapped_dek, + wrap_nonce=encrypted.wrap_nonce, + kek_key_id=encrypted.kek_key_id, + schema_version=encrypted.schema_version, + status="active", + access_token_expires_at=encrypted.access_token_expires_at, + refresh_token_expires_at=encrypted.refresh_token_expires_at, + credential_updated_at=observed_at, + created_at=observed_at, + updated_at=observed_at, + version=1, + ) + session.add(record) + else: + _replace_encrypted_credential(record, encrypted) + record.status = "active" + record.credential_updated_at = observed_at + record.updated_at = observed_at + record.version += 1 + session.flush() + return _safe_provider_connection(record) + except IntegrityError: + raise ProviderConnectionPersistenceError( + "Yandex provider connection could not be persisted" + ) from None + + def rewrap_to_active_key( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> YandexProviderConnection: + observed_at = _utc_now(now) + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + record = session.scalar( + select(YandexProviderConnectionRecord) + .where( + YandexProviderConnectionRecord.id == connection_id, + YandexProviderConnectionRecord.workspace_id == workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + ) + .with_for_update() + ) + if record is None: + raise ProviderConnectionPersistenceError("Yandex provider connection is unavailable") + rewrapped = self._vault.rewrap_to_active_key( + context=CredentialContext.for_yandex_connection( + workspace_id=workspace_id, + connection_id=record.id, + ), + encrypted=_encrypted_credential(record), + ) + record.wrapped_dek = rewrapped.wrapped_dek + record.wrap_nonce = rewrapped.wrap_nonce + record.kek_key_id = rewrapped.kek_key_id + record.schema_version = rewrapped.schema_version + record.updated_at = observed_at + record.version += 1 + session.flush() + return _safe_provider_connection(record) + + +def _replace_encrypted_credential( + record: YandexProviderConnectionRecord, + encrypted: EncryptedYandexCredential, +) -> None: + record.token_ciphertext = encrypted.token_ciphertext + record.token_nonce = encrypted.token_nonce + record.wrapped_dek = encrypted.wrapped_dek + record.wrap_nonce = encrypted.wrap_nonce + record.kek_key_id = encrypted.kek_key_id + record.schema_version = encrypted.schema_version + record.access_token_expires_at = encrypted.access_token_expires_at + record.refresh_token_expires_at = encrypted.refresh_token_expires_at + + +def _encrypted_credential(record: YandexProviderConnectionRecord) -> EncryptedYandexCredential: + return EncryptedYandexCredential( + token_ciphertext=record.token_ciphertext, + token_nonce=record.token_nonce, + wrapped_dek=record.wrapped_dek, + wrap_nonce=record.wrap_nonce, + kek_key_id=record.kek_key_id, + schema_version=record.schema_version, + access_token_expires_at=record.access_token_expires_at, + refresh_token_expires_at=record.refresh_token_expires_at, + ) + + +def _safe_provider_connection(record: YandexProviderConnectionRecord) -> YandexProviderConnection: + return YandexProviderConnection( + id=record.id, + workspace_id=record.workspace_id, + external_identity_id=record.external_identity_id, + provider=record.provider, + status=record.status, + access_token_expires_at=record.access_token_expires_at, + refresh_token_expires_at=record.refresh_token_expires_at, + credential_updated_at=record.credential_updated_at, + created_at=record.created_at, + updated_at=record.updated_at, + version=record.version, + ) + + def _utc_now(value: datetime | None) -> datetime: now = datetime.now(timezone.utc) if value is None else value if now.tzinfo is None: diff --git a/app/modules/integrations/yandex/router.py b/app/modules/integrations/yandex/router.py index 04951e3..a186f21 100644 --- a/app/modules/integrations/yandex/router.py +++ b/app/modules/integrations/yandex/router.py @@ -7,10 +7,12 @@ from app.bootstrap.dependencies import get_application_dependencies from app.core.errors import safe_error_response +from app.modules.integrations.yandex.credentials import CredentialVaultError from app.modules.integrations.yandex.oauth import ( OAuthCallbackRejected, OAuthCallbackService, OAuthClientMismatch, + OAuthCredentialPersistenceUnavailable, OAuthStartService, YandexOAuthConfiguration, YandexOAuthIntegration, @@ -20,7 +22,10 @@ HttpxYandexOAuthProvider, YandexOAuthProviderFailure, ) -from app.modules.integrations.yandex.repository import ExternalIdentityConflict +from app.modules.integrations.yandex.repository import ( + ExternalIdentityConflict, + ProviderConnectionPersistenceError, +) from app.modules.sessions.cookies import SESSION_COOKIE_NAME, set_session_cookie from app.modules.sessions.service import AuthenticatedSession, PostgresSessionService from app.modules.tenancy.policy import AuthorizationDenied, Capability @@ -64,7 +69,11 @@ def _configured_integration( request: Request, ) -> tuple[YandexOAuthIntegration, YandexOAuthConfiguration] | Response: integration = get_application_dependencies(request).yandex_oauth - if integration is None or integration.config is None: + if ( + integration is None + or integration.config is None + or integration.credential_persister is None + ): return _safe_error(request, status_code=503, code="oauth_unavailable") return integration, integration.config @@ -124,6 +133,7 @@ def complete_yandex_oauth( transactions=integration.transactions, identities=integration.identities, provider=_provider(integration, config), + credential_persister=integration.credential_persister, ).complete( code=code, state=state, @@ -137,6 +147,10 @@ def complete_yandex_oauth( return _safe_error(request, status_code=502, code="oauth_provider_error") except ExternalIdentityConflict: return _safe_error(request, status_code=409, code="oauth_identity_conflict") + except OAuthCredentialPersistenceUnavailable: + return _safe_error(request, status_code=503, code="oauth_credential_vault_unavailable") + except (CredentialVaultError, ProviderConnectionPersistenceError): + return _safe_error(request, status_code=503, code="oauth_credential_persistence_failed") except YandexOAuthProviderFailure as exc: if exc.kind == "invalid_grant": return _safe_error(request, status_code=400, code="oauth_reauthorization_required") diff --git a/pyproject.toml b/pyproject.toml index 944fb1c..66a137d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "sqlalchemy>=2.0,<2.1", "psycopg[binary]>=3.2,<3.3", "alembic>=1.14,<1.18", + "cryptography>=50.0.1", ] [dependency-groups] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..d0f3d97 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration-test package namespace for collision-free pytest collection.""" diff --git a/tests/integration/test_yandex_credentials.py b/tests/integration/test_yandex_credentials.py new file mode 100644 index 0000000..2af1d7a --- /dev/null +++ b/tests/integration/test_yandex_credentials.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import json +import logging +from dataclasses import replace +from datetime import datetime, timezone +from urllib.parse import parse_qs, urlsplit +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import create_application_dependencies +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import YandexProviderConnectionRecord +from app.db.rls import tenant_transaction +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.credentials import ( + CredentialContext, + CredentialKeyRing, + CredentialVault, + EncryptedYandexCredential, + YandexCredentialPayload, +) +from app.modules.integrations.yandex.oauth import ( + YandexOAuthConfiguration, + YandexOAuthIntegration, + YandexOAuthTokenSet, + YandexUserInfo, +) +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresOAuthTransactionRepository, + PostgresYandexProviderConnectionRepository, +) +from app.modules.sessions.service import PostgresSessionService +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +@pytest.mark.integration +def test_connection_repository_persists_only_an_authenticated_envelope( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime(2026, 9, 6, 13, tzinfo=timezone.utc) + access_token = "synthetic-access-token" + refresh_token = "synthetic-refresh-token" + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic credential owner", + workspace_name="Synthetic credential workspace", + ) + subject = "synthetic-stable-yandex-subject" + PostgresExternalIdentityRepository(app_runtime.sessions).bind_yandex_identity( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + profile_login="synthetic-login", + profile_display_name="Synthetic display name", + now=now, + ) + vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + connections = PostgresYandexProviderConnectionRepository( + app_runtime.sessions, + vault=vault, + ) + payload = YandexCredentialPayload( + access_token=access_token, + refresh_token=refresh_token, + access_token_expires_at=now, + refresh_token_expires_at=None, + ) + + connection = connections.persist_yandex_oauth_tokens( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + payload=payload, + now=now, + ) + + assert connection.workspace_id == identity.workspace.id + assert connection.status == "active" + assert connection.access_token_expires_at == now + with tenant_transaction( + app_runtime.sessions, + workspace_id=identity.workspace.id, + user_id=identity.user.id, + ) as session: + record = session.scalar(select(YandexProviderConnectionRecord)) + assert record is not None + assert record.id == connection.id + assert record.token_ciphertext != access_token.encode() + assert record.token_nonce != b"" + assert record.wrapped_dek != b"" + assert {"access_token", "refresh_token"}.isdisjoint(record.__table__.columns.keys()) + persisted = {column.name: getattr(record, column.name) for column in record.__table__.columns} + assert access_token not in repr(persisted) + assert refresh_token not in repr(persisted) + decrypted = vault.decrypt( + context=CredentialContext.for_yandex_connection( + workspace_id=identity.workspace.id, + connection_id=record.id, + ), + encrypted=EncryptedYandexCredential( + token_ciphertext=record.token_ciphertext, + token_nonce=record.token_nonce, + wrapped_dek=record.wrapped_dek, + wrap_nonce=record.wrap_nonce, + kek_key_id=record.kek_key_id, + schema_version=record.schema_version, + access_token_expires_at=record.access_token_expires_at, + refresh_token_expires_at=record.refresh_token_expires_at, + ), + ) + assert decrypted == payload + finally: + owner_runtime.close() + app_runtime.close() + + +class _CallbackProvider: + def __init__(self, config: YandexOAuthConfiguration) -> None: + self._config = config + + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: + assert code == "synthetic-authorization-code" + assert code_verifier + return YandexOAuthTokenSet( + access_token="synthetic-access-token", + refresh_token="synthetic-refresh-token", + expires_in=3600, + ) + + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: + assert access_token == "synthetic-access-token" + return YandexUserInfo( + subject="synthetic-stable-yandex-subject", + client_id=self._config.client_id, + login="synthetic-login", + display_name="Synthetic display name", + ) + + +@pytest.mark.integration +def test_callback_persists_encrypted_credentials_and_redirects_without_secret_output( + postgres_service: object, + caplog: pytest.LogCaptureFixture, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + access_token = "synthetic-access-token" + refresh_token = "synthetic-refresh-token" + authorization_code = "synthetic-authorization-code" + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + connections = PostgresYandexProviderConnectionRepository( + app_runtime.sessions, + vault=vault, + ) + dependencies = replace( + create_application_dependencies(), + repository=PostgresLegacyStoreRepository(app_runtime.sessions), + identity_repository=PostgresIdentityRepository(app_runtime.sessions), + session_service=PostgresSessionService(app_runtime.sessions), + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + yandex_oauth=YandexOAuthIntegration( + config=config, + transactions=PostgresOAuthTransactionRepository(app_runtime.sessions), + identities=PostgresExternalIdentityRepository(app_runtime.sessions), + provider=_CallbackProvider(config), + credential_persister=connections, + ), + ) + client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") + login = client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic callback credential owner"}, + ) + assert login.status_code == 200 + user_id = UUID(login.json()["user_id"]) + workspace_id = UUID(login.json()["workspace_id"]) + started = client.get( + "/api/v1/integrations/yandex/start", + follow_redirects=False, + ) + assert started.status_code == 302 + state = parse_qs(urlsplit(started.headers["location"]).query, strict_parsing=True)["state"][0] + caplog.set_level(logging.INFO, logger="directpilot.request") + + callback = client.get( + "/api/v1/integrations/yandex/callback", + params={"code": authorization_code, "state": state}, + follow_redirects=False, + ) + + assert callback.status_code == 303 + assert callback.headers["location"] == "/" + with tenant_transaction( + app_runtime.sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + record = session.scalar(select(YandexProviderConnectionRecord)) + assert record is not None + assert record.token_ciphertext != access_token.encode() + assert record.token_ciphertext != refresh_token.encode() + assert record.token_nonce + assert record.wrapped_dek + assert record.wrap_nonce + assert {"access_token", "refresh_token"}.isdisjoint(record.__table__.columns.keys()) + persisted = {column.name: getattr(record, column.name) for column in record.__table__.columns} + for forbidden_value in (access_token, refresh_token, authorization_code): + assert forbidden_value not in callback.text + assert forbidden_value not in repr(persisted) + assert forbidden_value not in "\n".join(str(item.__dict__) for item in caplog.records) + openapi = json.dumps(client.get("/openapi.json").json(), sort_keys=True) + for forbidden_field in ( + "token_ciphertext", + "token_nonce", + "wrapped_dek", + "wrap_nonce", + "code_verifier", + ): + assert forbidden_field not in openapi + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_yandex_oauth_http.py b/tests/integration/test_yandex_oauth_http.py index 303e7c1..419b1a1 100644 --- a/tests/integration/test_yandex_oauth_http.py +++ b/tests/integration/test_yandex_oauth_http.py @@ -15,10 +15,11 @@ from app.bootstrap.dependencies import create_application_dependencies from app.db.engine import DatabaseSettings, create_database_runtime from app.db.migrations.runner import upgrade_database -from app.db.models import ExternalIdentityRecord +from app.db.models import ExternalIdentityRecord, YandexProviderConnectionRecord from app.db.rls import tenant_transaction from app.db.roles import bootstrap_database_roles from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.credentials import YandexCredentialPayload from app.modules.integrations.yandex.oauth import ( YandexOAuthConfiguration, YandexOAuthIntegration, @@ -29,6 +30,7 @@ from app.modules.integrations.yandex.repository import ( PostgresExternalIdentityRepository, PostgresOAuthTransactionRepository, + ProviderConnectionPersistenceError, ) from app.modules.sessions.cookies import SESSION_COOKIE_NAME from app.modules.sessions.service import PostgresSessionService @@ -63,6 +65,27 @@ def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: ) +class _RecordingCredentialPersister: + def __init__(self) -> None: + self.calls = 0 + + def persist_yandex_oauth_tokens( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + payload: YandexCredentialPayload, + ) -> None: + assert user_id + assert workspace_id + assert issuer == "https://login.yandex.ru" + assert subject == "stable-yandex-user-id" + assert payload.access_token + self.calls += 1 + + @pytest.mark.integration def test_authenticated_oauth_callback_rotates_session_and_maps_stable_subject( postgres_service: object, @@ -89,6 +112,7 @@ def test_authenticated_oauth_callback_rotates_session_and_maps_stable_subject( redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", ) provider = _MockYandexOAuthProvider(config) + credential_persister = _RecordingCredentialPersister() session_service = PostgresSessionService(app_runtime.sessions) dependencies = replace( create_application_dependencies(), @@ -102,6 +126,7 @@ def test_authenticated_oauth_callback_rotates_session_and_maps_stable_subject( transactions=PostgresOAuthTransactionRepository(app_runtime.sessions), identities=PostgresExternalIdentityRepository(app_runtime.sessions), provider=provider, + credential_persister=credential_persister, ), ) client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") @@ -139,6 +164,7 @@ def test_authenticated_oauth_callback_rotates_session_and_maps_stable_subject( assert session_service.authenticate(rotated_session_token) is not None assert provider.exchange_calls == 1 assert provider.userinfo_calls == 1 + assert credential_persister.calls == 1 with tenant_transaction( app_runtime.sessions, workspace_id=workspace_id, @@ -212,6 +238,7 @@ def test_oauth_route_errors_do_not_echo_callback_or_secret_values( transactions=PostgresOAuthTransactionRepository(app_runtime.sessions), identities=PostgresExternalIdentityRepository(app_runtime.sessions), provider=provider, + credential_persister=_RecordingCredentialPersister(), ), ) client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") @@ -257,3 +284,153 @@ def test_oauth_route_errors_do_not_echo_callback_or_secret_values( finally: owner_runtime.close() app_runtime.close() + + +class _SuccessfulPersistenceFailureProvider: + access_token = "synthetic-access-token-for-persistence-failure" + refresh_token = "synthetic-refresh-token-for-persistence-failure" + + def __init__(self, config: YandexOAuthConfiguration) -> None: + self._config = config + + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: + assert code == "synthetic-callback-code-for-persistence-failure" + assert code_verifier + return YandexOAuthTokenSet( + access_token=self.access_token, + refresh_token=self.refresh_token, + expires_in=3600, + ) + + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: + assert access_token == self.access_token + return YandexUserInfo( + subject="stable-yandex-user-id", + client_id=self._config.client_id, + login="synthetic-login", + display_name="Synthetic display name", + ) + + +class _FailingCredentialPersister: + upstream_body = "synthetic-upstream-persistence-body" + + def __init__(self) -> None: + self.calls = 0 + + def persist_yandex_oauth_tokens( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + payload: YandexCredentialPayload, + ) -> None: + assert user_id + assert workspace_id + assert issuer == "https://login.yandex.ru" + assert subject == "stable-yandex-user-id" + assert payload.access_token + self.calls += 1 + raise ProviderConnectionPersistenceError(self.upstream_body) + + +@pytest.mark.integration +def test_credential_persistence_failure_is_safe_and_keeps_session_and_connection_absent( + postgres_service: object, + caplog: pytest.LogCaptureFixture, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + provider = _SuccessfulPersistenceFailureProvider(config) + credential_persister = _FailingCredentialPersister() + session_service = PostgresSessionService(app_runtime.sessions) + dependencies = replace( + create_application_dependencies(), + repository=PostgresLegacyStoreRepository(app_runtime.sessions), + identity_repository=PostgresIdentityRepository(app_runtime.sessions), + session_service=session_service, + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + yandex_oauth=YandexOAuthIntegration( + config=config, + transactions=PostgresOAuthTransactionRepository(app_runtime.sessions), + identities=PostgresExternalIdentityRepository(app_runtime.sessions), + provider=provider, + credential_persister=credential_persister, + ), + ) + client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") + login = client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic persistence failure owner"}, + ) + assert login.status_code == 200 + user_id = UUID(login.json()["user_id"]) + workspace_id = UUID(login.json()["workspace_id"]) + previous_session_token = client.cookies.get(SESSION_COOKIE_NAME) + assert previous_session_token is not None + start = client.get("/api/v1/integrations/yandex/start", follow_redirects=False) + assert start.status_code == 302 + state = parse_qs(urlsplit(start.headers["location"]).query, strict_parsing=True)["state"][0] + authorization_code = "synthetic-callback-code-for-persistence-failure" + caplog.set_level(logging.INFO, logger="directpilot.request") + + callback = client.get( + "/api/v1/integrations/yandex/callback", + params={"code": authorization_code, "state": state}, + follow_redirects=False, + ) + + assert callback.status_code == 503 + assert "oauth_credential_persistence_failed" in callback.text + assert callback.headers.get("location") is None + assert callback.headers.get("set-cookie") is None + current_session_token = client.cookies.get(SESSION_COOKIE_NAME) + assert current_session_token is not None + assert hmac.compare_digest(previous_session_token, current_session_token) + assert session_service.authenticate(previous_session_token) is not None + assert credential_persister.calls == 1 + with tenant_transaction( + app_runtime.sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + connections = session.scalars( + select(YandexProviderConnectionRecord).where( + YandexProviderConnectionRecord.workspace_id == workspace_id + ) + ).all() + assert connections == [] + sensitive_markers = ( + provider.access_token, + provider.refresh_token, + authorization_code, + credential_persister.upstream_body, + ) + assert not _contains_any(callback.text, sensitive_markers) + log_output = "\n".join(str(record.__dict__) for record in caplog.records) + assert not _contains_any(log_output, sensitive_markers) + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/test_yandex_credentials.py b/tests/test_yandex_credentials.py new file mode 100644 index 0000000..8508f29 --- /dev/null +++ b/tests/test_yandex_credentials.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import base64 +import json +import os +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest + +from app.modules.integrations.yandex.credentials import ( + CredentialConfigurationError, + CredentialContext, + CredentialKeyRing, + CredentialVault, + CredentialVaultError, + YandexCredentialPayload, +) + + +def test_vault_round_trip_returns_token_and_expiry_metadata_without_secret_repr() -> None: + key_ring = CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + vault = CredentialVault(key_ring) + context = CredentialContext.for_yandex_connection( + workspace_id=uuid4(), + connection_id=uuid4(), + ) + payload = YandexCredentialPayload( + access_token="synthetic-access-token", + refresh_token="synthetic-refresh-token", + access_token_expires_at=datetime(2026, 9, 6, 13, tzinfo=timezone.utc), + refresh_token_expires_at=datetime(2026, 10, 6, 13, tzinfo=timezone.utc), + ) + + encrypted = vault.encrypt(context=context, payload=payload) + decrypted = vault.decrypt(context=context, encrypted=encrypted) + + assert decrypted == payload + assert encrypted.kek_key_id == "test-kek-v1" + assert len(encrypted.token_nonce) == 12 + assert len(encrypted.wrap_nonce) == 12 + assert encrypted.token_ciphertext != payload.access_token.encode() + for protected_value in (payload.access_token, payload.refresh_token): + assert protected_value not in repr(payload) + assert protected_value not in repr(encrypted) + assert protected_value not in repr(decrypted) + + +def test_key_ring_loads_unpadded_base64url_keys_from_a_private_secret_file(tmp_path: Path) -> None: + secret_file = tmp_path / "credential-keyring.json" + encoded_key = base64.urlsafe_b64encode(b"s" * 32).rstrip(b"=").decode("ascii") + secret_file.write_text( + json.dumps( + { + "active_key_id": "test-kek-v1", + "keys": {"test-kek-v1": encoded_key}, + } + ), + encoding="utf-8", + ) + secret_file.chmod(0o600) + + key_ring = CredentialKeyRing.from_json_secret_file(secret_file) + + assert key_ring.active_key_id == "test-kek-v1" + assert "s" * 32 not in repr(key_ring) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX file modes are unavailable") +@pytest.mark.parametrize("mode", [0o640, 0o644]) +def test_key_ring_rejects_group_or_world_readable_secret_files(tmp_path: Path, mode: int) -> None: + secret_file = tmp_path / "credential-keyring.json" + secret_file.write_text('{"active_key_id":"test-kek-v1","keys":{}}', encoding="utf-8") + secret_file.chmod(mode) + + with pytest.raises(CredentialConfigurationError): + CredentialKeyRing.from_json_secret_file(secret_file) + + +def _credential_payload() -> YandexCredentialPayload: + return YandexCredentialPayload( + access_token="synthetic-access-token", + refresh_token="synthetic-refresh-token", + access_token_expires_at=datetime(2026, 9, 6, 13, tzinfo=timezone.utc), + refresh_token_expires_at=datetime(2026, 10, 6, 13, tzinfo=timezone.utc), + ) + + +def _flip_byte(value: bytes) -> bytes: + return bytes([value[0] ^ 1]) + value[1:] + + +@pytest.mark.parametrize( + "tamper_target", + [ + "token_ciphertext", + "token_nonce", + "wrapped_dek", + "wrap_nonce", + "kek_key_id", + "workspace_id", + "connection_id", + "token_purpose", + "wrap_purpose", + ], +) +def test_vault_fails_closed_for_every_authenticated_envelope_binding( + tamper_target: str, +) -> None: + key_ring = CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32, "test-kek-v2": b"v" * 32}, + ) + vault = CredentialVault(key_ring) + context = CredentialContext.for_yandex_connection( + workspace_id=uuid4(), + connection_id=uuid4(), + ) + encrypted = vault.encrypt(context=context, payload=_credential_payload()) + tampered_context = context + tampered_encrypted = encrypted + if tamper_target == "token_ciphertext": + tampered_encrypted = replace( + encrypted, + token_ciphertext=_flip_byte(encrypted.token_ciphertext), + ) + elif tamper_target == "token_nonce": + tampered_encrypted = replace(encrypted, token_nonce=_flip_byte(encrypted.token_nonce)) + elif tamper_target == "wrapped_dek": + tampered_encrypted = replace(encrypted, wrapped_dek=_flip_byte(encrypted.wrapped_dek)) + elif tamper_target == "wrap_nonce": + tampered_encrypted = replace(encrypted, wrap_nonce=_flip_byte(encrypted.wrap_nonce)) + elif tamper_target == "kek_key_id": + tampered_encrypted = replace(encrypted, kek_key_id="test-kek-v2") + elif tamper_target == "workspace_id": + tampered_context = replace(context, workspace_id=uuid4()) + elif tamper_target == "connection_id": + tampered_context = replace(context, connection_id=uuid4()) + elif tamper_target == "token_purpose": + tampered_context = replace(context, token_purpose="other-token-purpose") + elif tamper_target == "wrap_purpose": + tampered_context = replace(context, wrap_purpose="other-wrap-purpose") + else: + raise AssertionError("unknown tamper target") + + with pytest.raises(CredentialVaultError) as raised: + vault.decrypt(context=tampered_context, encrypted=tampered_encrypted) + + assert "synthetic-access-token" not in str(raised.value) + assert "synthetic-refresh-token" not in repr(raised.value) + + +def test_key_rotation_decrypts_historical_rows_and_rewrap_preserves_token_ciphertext() -> None: + v1_key_ring = CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + context = CredentialContext.for_yandex_connection( + workspace_id=uuid4(), + connection_id=uuid4(), + ) + payload = _credential_payload() + encrypted = CredentialVault(v1_key_ring).encrypt(context=context, payload=payload) + rotated_vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v2", + keys={"test-kek-v1": b"k" * 32, "test-kek-v2": b"v" * 32}, + ) + ) + + assert rotated_vault.decrypt(context=context, encrypted=encrypted) == payload + rewrapped = rotated_vault.rewrap_to_active_key(context=context, encrypted=encrypted) + + assert rewrapped.kek_key_id == "test-kek-v2" + assert rewrapped.token_ciphertext == encrypted.token_ciphertext + assert rewrapped.token_nonce == encrypted.token_nonce + assert rewrapped.wrapped_dek != encrypted.wrapped_dek + assert rewrapped.wrap_nonce != encrypted.wrap_nonce + assert rotated_vault.decrypt(context=context, encrypted=rewrapped) == payload diff --git a/tests/test_yandex_oauth_service.py b/tests/test_yandex_oauth_service.py index 263c3be..2a80247 100644 --- a/tests/test_yandex_oauth_service.py +++ b/tests/test_yandex_oauth_service.py @@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlsplit from uuid import UUID, uuid4 +from app.modules.integrations.yandex.credentials import YandexCredentialPayload from app.modules.integrations.yandex.oauth import ( ConsumedOAuthTransaction, NewOAuthTransaction, @@ -108,6 +109,30 @@ def bind_yandex_identity( self.subject = subject +class _RecordingCredentialPersister: + def __init__(self) -> None: + self.calls = 0 + self.user_id: UUID | None = None + self.workspace_id: UUID | None = None + self.subject: str | None = None + + def persist_yandex_oauth_tokens( + self, + *, + user_id: UUID, + workspace_id: UUID, + issuer: str, + subject: str, + payload: YandexCredentialPayload, + ) -> None: + assert issuer == "https://login.yandex.ru" + assert payload.access_token_expires_at > datetime.now(timezone.utc) + self.calls += 1 + self.user_id = user_id + self.workspace_id = workspace_id + self.subject = subject + + class _MockYandexOAuthProvider: def __init__(self, config: YandexOAuthConfiguration) -> None: self._config = config @@ -160,11 +185,13 @@ def test_callback_binds_the_stable_yandex_subject_to_the_authenticated_local_use ) identities = _RecordingIdentities() provider = _MockYandexOAuthProvider(config) + credential_persister = _RecordingCredentialPersister() service = OAuthCallbackService( config=config, transactions=transactions, identities=identities, provider=provider, + credential_persister=credential_persister, ) completed = service.complete( @@ -181,5 +208,9 @@ def test_callback_binds_the_stable_yandex_subject_to_the_authenticated_local_use assert identities.user_id == user_id assert identities.workspace_id == workspace_id assert identities.subject == "stable-yandex-user-id" + assert credential_persister.calls == 1 + assert credential_persister.user_id == user_id + assert credential_persister.workspace_id == workspace_id + assert credential_persister.subject == "stable-yandex-user-id" assert provider.userinfo_calls == 1 assert completed.return_path == "/" diff --git a/uv.lock b/uv.lock index e8851e5..3fdeeb5 100644 --- a/uv.lock +++ b/uv.lock @@ -124,6 +124,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -145,12 +243,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "directpilot-beta" version = "0.2.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx", extra = ["socks"] }, { name = "psycopg", extra = ["binary"] }, @@ -172,6 +327,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14,<1.18" }, + { name = "cryptography", specifier = ">=50.0.1" }, { name = "fastapi", specifier = ">=0.111" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<3.3" }, @@ -759,6 +915,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/b2/411d4180252144f7eff024894d2d2ebb98c012c944a282fc20250870e461/psycopg_binary-3.2.13-cp314-cp314-win_amd64.whl", hash = "sha256:5c77f156c7316529ed371b5f95a51139e531328ee39c37493a2afcbc1f79d5de", size = 3000162, upload-time = "2025-11-21T22:33:07.378Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" From 00d000fcfd5e06890b56834cd2447b5518e886d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 03:31:20 +0300 Subject: [PATCH 3/8] feat: add Yandex connection refresh lifecycle --- app/bootstrap/dependencies.py | 26 +- .../20260906_0009_yandex_connection_delete.py | 18 + app/modules/integrations/yandex/provider.py | 52 ++- app/modules/integrations/yandex/refresh.py | 178 ++++++++++ app/modules/integrations/yandex/repository.py | 327 +++++++++++++++++- app/modules/integrations/yandex/router.py | 161 +++++++++ docs/openapi.json | 154 +++++++++ tests/integration/test_yandex_refresh.py | 326 +++++++++++++++++ tests/integration/test_yandex_refresh_http.py | 149 ++++++++ tests/test_yandex_oauth_provider.py | 44 +++ tests/test_yandex_refresh.py | 24 ++ 11 files changed, 1450 insertions(+), 9 deletions(-) create mode 100644 app/db/migrations/versions/20260906_0009_yandex_connection_delete.py create mode 100644 app/modules/integrations/yandex/refresh.py create mode 100644 tests/integration/test_yandex_refresh.py create mode 100644 tests/integration/test_yandex_refresh_http.py create mode 100644 tests/test_yandex_refresh.py diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py index 25c49d5..2511e29 100644 --- a/app/bootstrap/dependencies.py +++ b/app/bootstrap/dependencies.py @@ -24,6 +24,11 @@ YandexOAuthConfiguration, YandexOAuthIntegration, ) +from app.modules.integrations.yandex.provider import HttpxYandexOAuthProvider +from app.modules.integrations.yandex.refresh import ( + YandexConnectionLifecycle, + YandexConnectionLifecycleService, +) from app.modules.integrations.yandex.repository import ( PostgresExternalIdentityRepository, PostgresOAuthTransactionRepository, @@ -65,6 +70,7 @@ class ApplicationDependencies: session_service: PostgresSessionService | None = None workspace_authorizer: PostgresWorkspaceAuthorizer | None = None yandex_oauth: YandexOAuthIntegration | None = None + yandex_connection_lifecycle: YandexConnectionLifecycle | None = None fake_auth_enabled: bool = False @@ -101,14 +107,22 @@ def create_application_dependencies() -> ApplicationDependencies: DatabaseSettings.from_mapping({DATABASE_URL_ENV: database_url}) ) try: - credential_persister = ( - PostgresYandexProviderConnectionRepository( + credential_persister = None + yandex_provider = None + connection_lifecycle = None + if oauth_config is not None: + connection_repository = PostgresYandexProviderConnectionRepository( runtime.sessions, vault=_configured_credential_vault(settings), ) - if oauth_config is not None - else None - ) + credential_persister = connection_repository + yandex_provider = HttpxYandexOAuthProvider( + config=oauth_config, + ) + connection_lifecycle = YandexConnectionLifecycleService( + repository=connection_repository, + provider=yandex_provider, + ) check_schema_compatibility(runtime) except Exception: runtime.close() @@ -126,8 +140,10 @@ def create_application_dependencies() -> ApplicationDependencies: transactions=PostgresOAuthTransactionRepository(runtime.sessions), identities=PostgresExternalIdentityRepository(runtime.sessions), config=oauth_config, + provider=yandex_provider, credential_persister=credential_persister, ), + yandex_connection_lifecycle=connection_lifecycle, fake_auth_enabled=_fake_auth_is_enabled(app_env), ) if app_env in {"production", "staging"}: diff --git a/app/db/migrations/versions/20260906_0009_yandex_connection_delete.py b/app/db/migrations/versions/20260906_0009_yandex_connection_delete.py new file mode 100644 index 0000000..5b08575 --- /dev/null +++ b/app/db/migrations/versions/20260906_0009_yandex_connection_delete.py @@ -0,0 +1,18 @@ +"""Grant local Yandex connection deletion to the runtime app role.""" + +from __future__ import annotations + +from alembic import op + +revision = "20260906_0009" +down_revision = "20260906_0008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("GRANT DELETE ON TABLE yandex_provider_connections TO directpilot_app") + + +def downgrade() -> None: + op.execute("REVOKE DELETE ON TABLE yandex_provider_connections FROM directpilot_app") diff --git a/app/modules/integrations/yandex/provider.py b/app/modules/integrations/yandex/provider.py index aa7bb28..6ebaaa9 100644 --- a/app/modules/integrations/yandex/provider.py +++ b/app/modules/integrations/yandex/provider.py @@ -41,14 +41,18 @@ def __init__( *, config: YandexOAuthConfiguration, transport: httpx.BaseTransport | None = None, + timeout_seconds: float = 10.0, ) -> None: + if timeout_seconds <= 0: + raise ValueError("Yandex OAuth timeout must be positive") self._config = config self._transport = transport + self._timeout_seconds = timeout_seconds def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: try: with httpx.Client( - timeout=httpx.Timeout(10.0), + timeout=httpx.Timeout(self._timeout_seconds), transport=self._transport, ) as client: response = client.post( @@ -89,10 +93,54 @@ def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet token_type=token_type.lower(), ) + def refresh_tokens(self, *, refresh_token: str) -> YandexOAuthTokenSet: + """Refresh one encrypted connection's token pair through the bounded OAuth seam.""" + try: + with httpx.Client( + timeout=httpx.Timeout(self._timeout_seconds), + transport=self._transport, + ) as client: + response = client.post( + _TOKEN_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + auth=(self._config.client_id, self._config.client_secret), + ) + except httpx.HTTPError: + raise YandexOAuthProviderFailure("provider_unavailable") from None + if response.is_error: + raise _token_failure(response) + payload = _json_mapping(response, kind="provider_response_invalid") + token_type = payload.get("token_type") + access_token = payload.get("access_token") + returned_refresh_token = payload.get("refresh_token") + expires_in = payload.get("expires_in") + if ( + not isinstance(token_type, str) + or token_type.lower() != "bearer" + or not isinstance(access_token, str) + or not access_token + or not isinstance(returned_refresh_token, str) + or not returned_refresh_token + or not isinstance(expires_in, int) + or isinstance(expires_in, bool) + or expires_in <= 0 + ): + raise YandexOAuthProviderFailure("provider_response_invalid") + return YandexOAuthTokenSet( + access_token=access_token, + refresh_token=returned_refresh_token, + expires_in=expires_in, + token_type=token_type.lower(), + ) + def fetch_user_info(self, *, access_token: str) -> YandexUserInfo: try: with httpx.Client( - timeout=httpx.Timeout(10.0), + timeout=httpx.Timeout(self._timeout_seconds), transport=self._transport, ) as client: response = client.get( diff --git a/app/modules/integrations/yandex/refresh.py b/app/modules/integrations/yandex/refresh.py new file mode 100644 index 0000000..003793c --- /dev/null +++ b/app/modules/integrations/yandex/refresh.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Protocol +from uuid import UUID + +from app.modules.integrations.yandex.oauth import YandexOAuthTokenSet + +DEFAULT_REFRESH_SKEW_SECONDS = 60 + + +class YandexConnectionLifecycleError(RuntimeError): + """Base class for token-free Yandex connection lifecycle outcomes.""" + + def __init__(self) -> None: + super().__init__("Yandex connection lifecycle operation failed") + + +class YandexConnectionNotFound(YandexConnectionLifecycleError): + """Raised when a workspace-scoped connection is absent or inaccessible.""" + + +class YandexConnectionNotActive(YandexConnectionLifecycleError): + """Raised when a retained connection cannot safely refresh.""" + + +class YandexConnectionCredentialUnavailable(YandexConnectionLifecycleError): + """Raised when an encrypted credential cannot be used safely.""" + + +class YandexConnectionReauthorizationRequired(YandexConnectionLifecycleError): + """Raised after invalid_grant destroys the local credential material.""" + + +class YandexConnectionProviderUnavailable(YandexConnectionLifecycleError): + """Raised for safe upstream/transport/response failures.""" + + +class YandexConnectionProviderConfigurationFailure(YandexConnectionLifecycleError): + """Raised for safe upstream client/application configuration failures.""" + + +class YandexConnectionPersistenceFailure(YandexConnectionLifecycleError): + """Raised when a provider result cannot be safely committed.""" + + +@dataclass(frozen=True, slots=True) +class RefreshResult: + """Safe metadata returned after an explicit connection refresh attempt.""" + + connection_id: UUID + status: str + refreshed: bool + access_token_expires_at: datetime + credential_version: int + + +@dataclass(frozen=True, slots=True) +class DisconnectResult: + """Safe metadata returned after local credential deletion.""" + + connection_id: UUID + status: str = "disconnected" + local_credentials_purged: bool = True + provider_revocation: str = "not_supported_for_current_grant" + yandex_revocation_url: str = "https://id.yandex.ru/personal/data-access" + + +class YandexRefreshProvider(Protocol): + def refresh_tokens(self, *, refresh_token: str) -> YandexOAuthTokenSet: ... + + +class YandexConnectionLifecycleRepository(Protocol): + def refresh_yandex_connection( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + provider: YandexRefreshProvider, + skew_seconds: int, + now: datetime | None = None, + ) -> RefreshResult: ... + + def disconnect_yandex_connection( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> DisconnectResult: ... + + +class YandexConnectionLifecycle(Protocol): + def refresh( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> RefreshResult: ... + + def disconnect( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> DisconnectResult: ... + + +class YandexConnectionLifecycleService: + """Explicit P4-04 refresh/disconnect boundary; no transparent client refresh.""" + + def __init__( + self, + *, + repository: YandexConnectionLifecycleRepository, + provider: YandexRefreshProvider, + refresh_skew_seconds: int = DEFAULT_REFRESH_SKEW_SECONDS, + ) -> None: + _validated_skew_seconds(refresh_skew_seconds) + self._repository = repository + self._provider = provider + self._refresh_skew_seconds = refresh_skew_seconds + + def refresh( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> RefreshResult: + return self._repository.refresh_yandex_connection( + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + provider=self._provider, + skew_seconds=self._refresh_skew_seconds, + now=now, + ) + + def disconnect( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> DisconnectResult: + return self._repository.disconnect_yandex_connection( + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + now=now, + ) + + +def is_refresh_due(*, now: datetime, expires_at: datetime, skew_seconds: int) -> bool: + """Return true at the exact expiry-minus-skew boundary, always in UTC.""" + _validated_skew_seconds(skew_seconds) + return _utc_timestamp(now) >= _utc_timestamp(expires_at) - timedelta(seconds=skew_seconds) + + +def _validated_skew_seconds(value: int) -> None: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError("Yandex refresh skew must be a non-negative integer") + + +def _utc_timestamp(value: datetime) -> datetime: + if value.tzinfo is None: + raise ValueError("Yandex refresh time must be timezone-aware") + return value.astimezone(timezone.utc) diff --git a/app/modules/integrations/yandex/repository.py b/app/modules/integrations/yandex/repository.py index f0392a5..05fbc48 100644 --- a/app/modules/integrations/yandex/repository.py +++ b/app/modules/integrations/yandex/repository.py @@ -1,30 +1,50 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 from sqlalchemy import select -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session, sessionmaker from app.db.models import ( + AuditEventRecord, ExternalIdentityRecord, OAuthTransactionRecord, YandexProviderConnectionRecord, ) from app.db.rls import tenant_transaction +from app.modules.audit.repository import sanitize_audit_metadata from app.modules.integrations.yandex.credentials import ( CredentialContext, + CredentialPayloadError, CredentialVault, + CredentialVaultError, EncryptedYandexCredential, YandexCredentialPayload, ) from app.modules.integrations.yandex.oauth import ( ConsumedOAuthTransaction, NewOAuthTransaction, + YandexOAuthTokenSet, hash_oauth_state, ) +from app.modules.integrations.yandex.provider import YandexOAuthProviderFailure +from app.modules.integrations.yandex.refresh import ( + DisconnectResult, + RefreshResult, + YandexConnectionCredentialUnavailable, + YandexConnectionLifecycleError, + YandexConnectionNotActive, + YandexConnectionNotFound, + YandexConnectionPersistenceFailure, + YandexConnectionProviderConfigurationFailure, + YandexConnectionProviderUnavailable, + YandexConnectionReauthorizationRequired, + YandexRefreshProvider, + is_refresh_due, +) class ExternalIdentityConflict(RuntimeError): @@ -295,6 +315,229 @@ def persist_yandex_oauth_tokens( "Yandex provider connection could not be persisted" ) from None + def refresh_yandex_connection( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + provider: YandexRefreshProvider, + skew_seconds: int, + now: datetime | None = None, + ) -> RefreshResult: + """Refresh one due active connection while retaining its PostgreSQL row lock.""" + result: RefreshResult | None = None + failure: YandexConnectionLifecycleError | None = None + try: + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + record = session.scalar( + select(YandexProviderConnectionRecord) + .where( + YandexProviderConnectionRecord.id == connection_id, + YandexProviderConnectionRecord.workspace_id == workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + ) + .with_for_update() + ) + lock_checked_at = _utc_now(now) + if record is None: + failure = YandexConnectionNotFound() + elif record.status != "active": + failure = YandexConnectionNotActive() + elif not is_refresh_due( + now=lock_checked_at, + expires_at=record.access_token_expires_at, + skew_seconds=skew_seconds, + ): + result = _refresh_result(record, refreshed=False) + else: + try: + decrypted = self._vault.decrypt( + context=CredentialContext.for_yandex_connection( + workspace_id=workspace_id, + connection_id=record.id, + ), + encrypted=_encrypted_credential(record), + ) + except CredentialVaultError: + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="credential_unavailable", + ) + failure = YandexConnectionCredentialUnavailable() + else: + tokens: YandexOAuthTokenSet | None = None + try: + tokens = provider.refresh_tokens(refresh_token=decrypted.refresh_token) + except YandexOAuthProviderFailure as exc: + if exc.kind == "invalid_grant": + session.delete(record) + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="refresh_reauth_required", + ) + failure = YandexConnectionReauthorizationRequired() + elif exc.kind in {"invalid_client", "unauthorized_client"}: + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="provider_configuration_error", + ) + failure = YandexConnectionProviderConfigurationFailure() + else: + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="provider_unavailable", + ) + failure = YandexConnectionProviderUnavailable() + finally: + del decrypted + if failure is None and tokens is None: + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="provider_response_invalid", + ) + failure = YandexConnectionProviderUnavailable() + if tokens is not None: + try: + refreshed_payload = _refreshed_credential_payload( + tokens, + received_at=_utc_now(now), + ) + except (CredentialPayloadError, ValueError): + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="provider_response_invalid", + ) + failure = YandexConnectionProviderUnavailable() + else: + try: + encrypted = self._vault.encrypt( + context=CredentialContext.for_yandex_connection( + workspace_id=workspace_id, + connection_id=record.id, + ), + payload=refreshed_payload, + ) + except CredentialVaultError: + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="refresh_persistence_failed", + ) + failure = YandexConnectionPersistenceFailure() + else: + _replace_encrypted_credential(record, encrypted) + record.status = "active" + record.credential_updated_at = lock_checked_at + record.updated_at = lock_checked_at + record.version += 1 + result = _refresh_result(record, refreshed=True) + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_refresh", + outcome="refreshed", + ) + finally: + del refreshed_payload + del tokens + session.flush() + except SQLAlchemyError: + raise YandexConnectionPersistenceFailure() from None + if failure is not None: + raise failure + if result is None: + raise YandexConnectionPersistenceFailure() + return result + + def disconnect_yandex_connection( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> DisconnectResult: + """Hard-delete one local connection without attempting unsupported upstream revoke.""" + del now + result: DisconnectResult | None = None + failure: YandexConnectionLifecycleError | None = None + try: + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + record = session.scalar( + select(YandexProviderConnectionRecord) + .where( + YandexProviderConnectionRecord.id == connection_id, + YandexProviderConnectionRecord.workspace_id == workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + ) + .with_for_update() + ) + if record is not None: + session.delete(record) + _append_lifecycle_audit( + session, + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + action="yandex_connection_disconnect", + outcome="disconnect_local", + ) + result = DisconnectResult(connection_id=connection_id) + elif _has_prior_disconnect_audit( + session, + workspace_id=workspace_id, + connection_id=connection_id, + ): + result = DisconnectResult(connection_id=connection_id) + else: + failure = YandexConnectionNotFound() + session.flush() + except SQLAlchemyError: + raise YandexConnectionPersistenceFailure() from None + if failure is not None: + raise failure + if result is None: + raise YandexConnectionPersistenceFailure() + return result + def rewrap_to_active_key( self, *, @@ -337,6 +580,86 @@ def rewrap_to_active_key( return _safe_provider_connection(record) +def _refresh_result( + record: YandexProviderConnectionRecord, + *, + refreshed: bool, +) -> RefreshResult: + return RefreshResult( + connection_id=record.id, + status=record.status, + refreshed=refreshed, + access_token_expires_at=record.access_token_expires_at, + credential_version=record.version, + ) + + +def _refreshed_credential_payload( + tokens: YandexOAuthTokenSet, + *, + received_at: datetime, +) -> YandexCredentialPayload: + if ( + not isinstance(tokens.token_type, str) + or tokens.token_type.lower() != "bearer" + or not isinstance(tokens.access_token, str) + or not tokens.access_token + or not isinstance(tokens.refresh_token, str) + or not tokens.refresh_token + or not isinstance(tokens.expires_in, int) + or isinstance(tokens.expires_in, bool) + or tokens.expires_in <= 0 + ): + raise CredentialPayloadError("Yandex refresh response is invalid") + expires_at = _utc_now(received_at) + timedelta(seconds=tokens.expires_in) + return YandexCredentialPayload( + access_token=tokens.access_token, + refresh_token=tokens.refresh_token, + access_token_expires_at=expires_at, + refresh_token_expires_at=expires_at, + ) + + +def _append_lifecycle_audit( + session: Session, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + action: str, + outcome: str, +) -> None: + session.add( + AuditEventRecord( + id=uuid4(), + workspace_id=workspace_id, + actor=str(user_id), + action=action, + entity=f"yandex_connection:{connection_id}", + dry_run=False, + details=sanitize_audit_metadata({"provider": "yandex", "outcome": outcome}), + ) + ) + + +def _has_prior_disconnect_audit( + session: Session, + *, + workspace_id: UUID, + connection_id: UUID, +) -> bool: + audit_id = session.scalar( + select(AuditEventRecord.id) + .where( + AuditEventRecord.workspace_id == workspace_id, + AuditEventRecord.action == "yandex_connection_disconnect", + AuditEventRecord.entity == f"yandex_connection:{connection_id}", + ) + .limit(1) + ) + return audit_id is not None + + def _replace_encrypted_credential( record: YandexProviderConnectionRecord, encrypted: EncryptedYandexCredential, diff --git a/app/modules/integrations/yandex/router.py b/app/modules/integrations/yandex/router.py index a186f21..b1d6c5b 100644 --- a/app/modules/integrations/yandex/router.py +++ b/app/modules/integrations/yandex/router.py @@ -1,8 +1,12 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal +from uuid import UUID from fastapi import APIRouter, Request +from pydantic import BaseModel from starlette.responses import RedirectResponse, Response from app.bootstrap.dependencies import get_application_dependencies @@ -22,6 +26,16 @@ HttpxYandexOAuthProvider, YandexOAuthProviderFailure, ) +from app.modules.integrations.yandex.refresh import ( + YandexConnectionCredentialUnavailable, + YandexConnectionLifecycle, + YandexConnectionNotActive, + YandexConnectionNotFound, + YandexConnectionPersistenceFailure, + YandexConnectionProviderConfigurationFailure, + YandexConnectionProviderUnavailable, + YandexConnectionReauthorizationRequired, +) from app.modules.integrations.yandex.repository import ( ExternalIdentityConflict, ProviderConnectionPersistenceError, @@ -33,6 +47,22 @@ router = APIRouter() +class YandexConnectionRefreshResponse(BaseModel): + connection_id: UUID + status: Literal["active"] + refreshed: bool + access_token_expires_at: datetime + credential_version: int + + +class YandexConnectionDisconnectResponse(BaseModel): + connection_id: UUID + status: Literal["disconnected"] + local_credentials_purged: Literal[True] + provider_revocation: Literal["not_supported_for_current_grant"] + yandex_revocation_url: Literal["https://id.yandex.ru/personal/data-access"] + + @dataclass(frozen=True, slots=True) class _OAuthBrowserSession: token: str = field(repr=False) @@ -78,6 +108,47 @@ def _configured_integration( return integration, integration.config +def _configured_lifecycle(request: Request) -> YandexConnectionLifecycle | Response: + lifecycle = get_application_dependencies(request).yandex_connection_lifecycle + if lifecycle is None: + return _safe_error(request, status_code=503, code="oauth_unavailable") + return lifecycle + + +def _empty_body_or_error(request: Request) -> Response | None: + content_length = request.headers.get("content-length") + if content_length not in {None, "0"} or request.headers.get("transfer-encoding") is not None: + return _safe_error(request, status_code=400, code="oauth_request_body_not_allowed") + return None + + +def _lifecycle_error_response( + request: Request, + *, + error: Exception, + disconnect: bool, +) -> Response: + if isinstance(error, YandexConnectionNotFound): + return _safe_error(request, status_code=404, code="connection_not_found") + if isinstance(error, YandexConnectionNotActive): + return _safe_error(request, status_code=409, code="connection_not_active") + if isinstance(error, YandexConnectionReauthorizationRequired): + return _safe_error(request, status_code=409, code="reauthorization_required") + if isinstance(error, YandexConnectionCredentialUnavailable): + return _safe_error(request, status_code=503, code="credential_unavailable") + if isinstance(error, YandexConnectionPersistenceFailure): + return _safe_error( + request, + status_code=503, + code="disconnect_persistence_failed" if disconnect else "refresh_persistence_failed", + ) + if isinstance(error, YandexConnectionProviderConfigurationFailure): + return _safe_error(request, status_code=503, code="oauth_unavailable") + if isinstance(error, YandexConnectionProviderUnavailable): + return _safe_error(request, status_code=502, code="oauth_provider_unavailable") + return _safe_error(request, status_code=503, code="oauth_unavailable") + + def _provider( integration: YandexOAuthIntegration, config: YandexOAuthConfiguration, @@ -85,6 +156,96 @@ def _provider( return integration.provider or HttpxYandexOAuthProvider(config=config) +@router.post( + "/api/v1/integrations/yandex/connections/{connection_id}/refresh", + response_model=YandexConnectionRefreshResponse, +) +def refresh_yandex_connection( + request: Request, + connection_id: UUID, +) -> YandexConnectionRefreshResponse | Response: + browser = _authenticated_oauth_session(request) + if isinstance(browser, Response): + return browser + body_error = _empty_body_or_error(request) + if body_error is not None: + return body_error + lifecycle = _configured_lifecycle(request) + if isinstance(lifecycle, Response): + return lifecycle + workspace_id = browser.principal.active_workspace_id + if workspace_id is None: + return _safe_error(request, status_code=401, code="oauth_authentication_required") + try: + result = lifecycle.refresh( + user_id=browser.principal.user_id, + workspace_id=workspace_id, + connection_id=connection_id, + ) + except ( + YandexConnectionNotFound, + YandexConnectionNotActive, + YandexConnectionReauthorizationRequired, + YandexConnectionCredentialUnavailable, + YandexConnectionPersistenceFailure, + YandexConnectionProviderConfigurationFailure, + YandexConnectionProviderUnavailable, + ) as error: + return _lifecycle_error_response(request, error=error, disconnect=False) + return YandexConnectionRefreshResponse( + connection_id=result.connection_id, + status="active", + refreshed=result.refreshed, + access_token_expires_at=result.access_token_expires_at, + credential_version=result.credential_version, + ) + + +@router.post( + "/api/v1/integrations/yandex/connections/{connection_id}/disconnect", + response_model=YandexConnectionDisconnectResponse, +) +def disconnect_yandex_connection( + request: Request, + connection_id: UUID, +) -> YandexConnectionDisconnectResponse | Response: + browser = _authenticated_oauth_session(request) + if isinstance(browser, Response): + return browser + body_error = _empty_body_or_error(request) + if body_error is not None: + return body_error + lifecycle = _configured_lifecycle(request) + if isinstance(lifecycle, Response): + return lifecycle + workspace_id = browser.principal.active_workspace_id + if workspace_id is None: + return _safe_error(request, status_code=401, code="oauth_authentication_required") + try: + result = lifecycle.disconnect( + user_id=browser.principal.user_id, + workspace_id=workspace_id, + connection_id=connection_id, + ) + except ( + YandexConnectionNotFound, + YandexConnectionNotActive, + YandexConnectionReauthorizationRequired, + YandexConnectionCredentialUnavailable, + YandexConnectionPersistenceFailure, + YandexConnectionProviderConfigurationFailure, + YandexConnectionProviderUnavailable, + ) as error: + return _lifecycle_error_response(request, error=error, disconnect=True) + return YandexConnectionDisconnectResponse( + connection_id=result.connection_id, + status="disconnected", + local_credentials_purged=True, + provider_revocation="not_supported_for_current_grant", + yandex_revocation_url="https://id.yandex.ru/personal/data-access", + ) + + @router.get("/api/v1/integrations/yandex/start") def start_yandex_oauth(request: Request, return_path: str = "/") -> Response: browser = _authenticated_oauth_session(request) diff --git a/docs/openapi.json b/docs/openapi.json index b83feee..bf0235d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -7055,6 +7055,80 @@ "title": "YandexCampaignList", "type": "object" }, + "YandexConnectionDisconnectResponse": { + "properties": { + "connection_id": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + }, + "local_credentials_purged": { + "const": true, + "title": "Local Credentials Purged", + "type": "boolean" + }, + "provider_revocation": { + "const": "not_supported_for_current_grant", + "title": "Provider Revocation", + "type": "string" + }, + "status": { + "const": "disconnected", + "title": "Status", + "type": "string" + }, + "yandex_revocation_url": { + "const": "https://id.yandex.ru/personal/data-access", + "title": "Yandex Revocation Url", + "type": "string" + } + }, + "required": [ + "connection_id", + "status", + "local_credentials_purged", + "provider_revocation", + "yandex_revocation_url" + ], + "title": "YandexConnectionDisconnectResponse", + "type": "object" + }, + "YandexConnectionRefreshResponse": { + "properties": { + "access_token_expires_at": { + "format": "date-time", + "title": "Access Token Expires At", + "type": "string" + }, + "connection_id": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + }, + "credential_version": { + "title": "Credential Version", + "type": "integer" + }, + "refreshed": { + "title": "Refreshed", + "type": "boolean" + }, + "status": { + "const": "active", + "title": "Status", + "type": "string" + } + }, + "required": [ + "connection_id", + "status", + "refreshed", + "access_token_expires_at", + "credential_version" + ], + "title": "YandexConnectionRefreshResponse", + "type": "object" + }, "YandexControlRequest": { "properties": { "approved": { @@ -8506,6 +8580,86 @@ "summary": "Complete Yandex Oauth" } }, + "/api/v1/integrations/yandex/connections/{connection_id}/disconnect": { + "post": { + "operationId": "disconnect_yandex_connection_api_v1_integrations_yandex_connections__connection_id__disconnect_post", + "parameters": [ + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/YandexConnectionDisconnectResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Disconnect Yandex Connection" + } + }, + "/api/v1/integrations/yandex/connections/{connection_id}/refresh": { + "post": { + "operationId": "refresh_yandex_connection_api_v1_integrations_yandex_connections__connection_id__refresh_post", + "parameters": [ + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/YandexConnectionRefreshResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Refresh Yandex Connection" + } + }, "/api/v1/integrations/yandex/start": { "get": { "operationId": "start_yandex_oauth_api_v1_integrations_yandex_start_get", diff --git a/tests/integration/test_yandex_refresh.py b/tests/integration/test_yandex_refresh.py new file mode 100644 index 0000000..f987a1b --- /dev/null +++ b/tests/integration/test_yandex_refresh.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from uuid import UUID + +import pytest +from sqlalchemy import select + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import AuditEventRecord, YandexProviderConnectionRecord +from app.db.rls import tenant_transaction +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.credentials import ( + CredentialContext, + CredentialKeyRing, + CredentialVault, + EncryptedYandexCredential, + YandexCredentialPayload, +) +from app.modules.integrations.yandex.oauth import YandexOAuthTokenSet +from app.modules.integrations.yandex.refresh import YandexConnectionLifecycleService +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresYandexProviderConnectionRepository, +) + + +class _RecordingRefreshProvider: + def __init__( + self, + *, + expected_refresh_token: str, + access_token: str, + returned_refresh_token: str, + ) -> None: + self._expected_refresh_token = expected_refresh_token + self._access_token = access_token + self._returned_refresh_token = returned_refresh_token + self.calls = 0 + + def refresh_tokens(self, *, refresh_token: str) -> YandexOAuthTokenSet: + assert refresh_token == self._expected_refresh_token + self.calls += 1 + return YandexOAuthTokenSet( + access_token=self._access_token, + refresh_token=self._returned_refresh_token, + expires_in=1800, + ) + + +def _vault() -> CredentialVault: + return CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + + +def _decrypt( + *, + vault: CredentialVault, + workspace_id: UUID, + record: YandexProviderConnectionRecord, +) -> YandexCredentialPayload: + return vault.decrypt( + context=CredentialContext.for_yandex_connection( + workspace_id=workspace_id, + connection_id=record.id, + ), + encrypted=EncryptedYandexCredential( + token_ciphertext=record.token_ciphertext, + token_nonce=record.token_nonce, + wrapped_dek=record.wrapped_dek, + wrap_nonce=record.wrap_nonce, + kek_key_id=record.kek_key_id, + schema_version=record.schema_version, + access_token_expires_at=record.access_token_expires_at, + refresh_token_expires_at=record.refresh_token_expires_at, + ), + ) + + +@pytest.mark.integration +def test_refresh_reencrypts_the_pair_and_bumps_version_when_access_token_is_unchanged( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime(2026, 9, 6, 12, tzinfo=timezone.utc) + access_token = "synthetic-unchanged-access-token" + old_refresh_token = "synthetic-old-refresh-token" + new_refresh_token = "synthetic-new-refresh-token" + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic refresh owner", + workspace_name="Synthetic refresh workspace", + ) + subject = "synthetic-refresh-subject" + PostgresExternalIdentityRepository(app_runtime.sessions).bind_yandex_identity( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + profile_login="synthetic-refresh-login", + profile_display_name="Synthetic refresh identity", + now=now, + ) + vault = _vault() + connections = PostgresYandexProviderConnectionRepository(app_runtime.sessions, vault=vault) + connection = connections.persist_yandex_oauth_tokens( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + payload=YandexCredentialPayload( + access_token=access_token, + refresh_token=old_refresh_token, + access_token_expires_at=now + timedelta(seconds=60), + refresh_token_expires_at=None, + ), + now=now, + ) + with tenant_transaction( + app_runtime.sessions, + workspace_id=identity.workspace.id, + user_id=identity.user.id, + ) as session: + before = session.scalar( + select(YandexProviderConnectionRecord).where( + YandexProviderConnectionRecord.id == connection.id + ) + ) + assert before is not None + old_ciphertext = before.token_ciphertext + old_wrapped_dek = before.wrapped_dek + provider = _RecordingRefreshProvider( + expected_refresh_token=old_refresh_token, + access_token=access_token, + returned_refresh_token=new_refresh_token, + ) + lifecycle = YandexConnectionLifecycleService( + repository=connections, + provider=provider, + refresh_skew_seconds=60, + ) + + result = lifecycle.refresh( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + connection_id=connection.id, + now=now, + ) + + assert provider.calls == 1 + assert result.connection_id == connection.id + assert result.status == "active" + assert result.refreshed is True + assert result.credential_version == 2 + assert result.access_token_expires_at == now + timedelta(seconds=1800) + assert access_token not in repr(result) + assert old_refresh_token not in repr(result) + assert new_refresh_token not in repr(result) + with tenant_transaction( + app_runtime.sessions, + workspace_id=identity.workspace.id, + user_id=identity.user.id, + ) as session: + after = session.scalar( + select(YandexProviderConnectionRecord).where( + YandexProviderConnectionRecord.id == connection.id + ) + ) + audit_events = session.scalars( + select(AuditEventRecord).where( + AuditEventRecord.workspace_id == identity.workspace.id + ) + ).all() + assert after is not None + assert after.version == 2 + assert after.token_ciphertext != old_ciphertext + assert after.wrapped_dek != old_wrapped_dek + assert _decrypt(vault=vault, workspace_id=identity.workspace.id, record=after) == ( + YandexCredentialPayload( + access_token=access_token, + refresh_token=new_refresh_token, + access_token_expires_at=now + timedelta(seconds=1800), + refresh_token_expires_at=now + timedelta(seconds=1800), + ) + ) + assert len(audit_events) == 1 + assert audit_events[0].action == "yandex_connection_refresh" + assert audit_events[0].details == {"outcome": "refreshed", "provider": "yandex"} + finally: + owner_runtime.close() + app_runtime.close() + + +class _NoRefreshProvider: + def __init__(self) -> None: + self.calls = 0 + + def refresh_tokens(self, *, refresh_token: str) -> YandexOAuthTokenSet: + del refresh_token + self.calls += 1 + raise AssertionError("disconnect must not invoke a provider refresh") + + +@pytest.mark.integration +def test_disconnect_hard_deletes_atomically_without_any_provider_call_and_is_repeat_safe( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime(2026, 9, 6, 12, tzinfo=timezone.utc) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic disconnect owner", + workspace_name="Synthetic disconnect workspace", + ) + subject = "synthetic-disconnect-subject" + PostgresExternalIdentityRepository(app_runtime.sessions).bind_yandex_identity( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + profile_login="synthetic-disconnect-login", + profile_display_name="Synthetic disconnect identity", + now=now, + ) + connections = PostgresYandexProviderConnectionRepository( + app_runtime.sessions, + vault=_vault(), + ) + connection = connections.persist_yandex_oauth_tokens( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + payload=YandexCredentialPayload( + access_token="synthetic-disconnect-access-token", + refresh_token="synthetic-disconnect-refresh-token", + access_token_expires_at=now + timedelta(hours=1), + refresh_token_expires_at=None, + ), + now=now, + ) + provider = _NoRefreshProvider() + lifecycle = YandexConnectionLifecycleService( + repository=connections, + provider=provider, + ) + + result = lifecycle.disconnect( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + connection_id=connection.id, + now=now, + ) + + assert result.connection_id == connection.id + assert result.status == "disconnected" + assert result.local_credentials_purged is True + assert result.provider_revocation == "not_supported_for_current_grant" + assert result.yandex_revocation_url == "https://id.yandex.ru/personal/data-access" + assert provider.calls == 0 + with tenant_transaction( + app_runtime.sessions, + workspace_id=identity.workspace.id, + user_id=identity.user.id, + ) as session: + record = session.scalar( + select(YandexProviderConnectionRecord).where( + YandexProviderConnectionRecord.id == connection.id + ) + ) + audit_events = session.scalars( + select(AuditEventRecord).where( + AuditEventRecord.workspace_id == identity.workspace.id + ) + ).all() + assert record is None + assert len(audit_events) == 1 + assert audit_events[0].action == "yandex_connection_disconnect" + assert audit_events[0].details == {"outcome": "disconnect_local", "provider": "yandex"} + + repeated = lifecycle.disconnect( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + connection_id=connection.id, + now=now, + ) + + assert repeated == result + assert provider.calls == 0 + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_yandex_refresh_http.py b/tests/integration/test_yandex_refresh_http.py new file mode 100644 index 0000000..daec7db --- /dev/null +++ b/tests/integration/test_yandex_refresh_http.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +from dataclasses import replace +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import create_application_dependencies +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.oauth import YandexOAuthIntegration +from app.modules.integrations.yandex.refresh import DisconnectResult, RefreshResult +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresOAuthTransactionRepository, +) +from app.modules.sessions.service import PostgresSessionService +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +class _RecordingLifecycle: + def __init__(self) -> None: + self.refresh_calls: list[tuple[UUID, UUID, UUID]] = [] + self.disconnect_calls: list[tuple[UUID, UUID, UUID]] = [] + + def refresh( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> RefreshResult: + del now + self.refresh_calls.append((user_id, workspace_id, connection_id)) + return RefreshResult( + connection_id=connection_id, + status="active", + refreshed=True, + access_token_expires_at=datetime(2026, 9, 6, 13, tzinfo=timezone.utc), + credential_version=2, + ) + + def disconnect( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + now: datetime | None = None, + ) -> DisconnectResult: + del now + self.disconnect_calls.append((user_id, workspace_id, connection_id)) + return DisconnectResult(connection_id=connection_id) + + +@pytest.mark.integration +def test_refresh_and_disconnect_routes_require_csrf_and_return_safe_schemas( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + lifecycle = _RecordingLifecycle() + dependencies = replace( + create_application_dependencies(), + repository=PostgresLegacyStoreRepository(app_runtime.sessions), + identity_repository=PostgresIdentityRepository(app_runtime.sessions), + session_service=PostgresSessionService(app_runtime.sessions), + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + yandex_oauth=YandexOAuthIntegration( + transactions=PostgresOAuthTransactionRepository(app_runtime.sessions), + identities=PostgresExternalIdentityRepository(app_runtime.sessions), + ), + yandex_connection_lifecycle=lifecycle, + ) + client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") + connection_id = uuid4() + refresh_path = f"/api/v1/integrations/yandex/connections/{connection_id}/refresh" + disconnect_path = f"/api/v1/integrations/yandex/connections/{connection_id}/disconnect" + + assert client.post(refresh_path).status_code == 401 + login = client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic lifecycle HTTP owner"}, + ) + assert login.status_code == 200 + user_id = UUID(login.json()["user_id"]) + workspace_id = UUID(login.json()["workspace_id"]) + csrf_token = login.json()["csrf_token"] + assert client.post(refresh_path).status_code == 403 + + headers = {"X-CSRF-Token": csrf_token, "Origin": "https://testserver"} + refreshed = client.post(refresh_path, headers=headers) + disconnected = client.post(disconnect_path, headers=headers) + + assert refreshed.status_code == 200 + assert refreshed.json() == { + "connection_id": str(connection_id), + "status": "active", + "refreshed": True, + "access_token_expires_at": "2026-09-06T13:00:00Z", + "credential_version": 2, + } + assert disconnected.status_code == 200 + assert disconnected.json() == { + "connection_id": str(connection_id), + "status": "disconnected", + "local_credentials_purged": True, + "provider_revocation": "not_supported_for_current_grant", + "yandex_revocation_url": "https://id.yandex.ru/personal/data-access", + } + assert lifecycle.refresh_calls == [(user_id, workspace_id, connection_id)] + assert lifecycle.disconnect_calls == [(user_id, workspace_id, connection_id)] + openapi = json.dumps(client.get("/openapi.json").json(), sort_keys=True) + assert "/api/v1/integrations/yandex/connections/{connection_id}/refresh" in openapi + assert "/api/v1/integrations/yandex/connections/{connection_id}/disconnect" in openapi + for forbidden_field in ( + '"access_token"', + '"refresh_token"', + '"client_secret"', + '"token_ciphertext"', + '"wrapped_dek"', + ): + assert forbidden_field not in openapi + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/test_yandex_oauth_provider.py b/tests/test_yandex_oauth_provider.py index 5be5adb..64b41d3 100644 --- a/tests/test_yandex_oauth_provider.py +++ b/tests/test_yandex_oauth_provider.py @@ -76,6 +76,50 @@ def handler(request: httpx.Request) -> httpx.Response: assert "refresh_token=" not in repr(tokens) +def test_provider_refresh_uses_exact_form_and_basic_auth_without_network() -> None: + config = YandexOAuthConfiguration( + client_id="synthetic-client-id", + client_secret="synthetic-client-secret", + redirect_uri="http://127.0.0.1:8000/api/v1/integrations/yandex/callback", + ) + refresh_token = secrets.token_urlsafe(32) + returned_access_token = secrets.token_urlsafe(32) + returned_refresh_token = secrets.token_urlsafe(32) + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + form = parse_qs(request.content.decode("ascii"), strict_parsing=True) + assert request.method == "POST" + assert str(request.url) == "https://oauth.yandex.ru/token" + assert request.headers["content-type"].startswith("application/x-www-form-urlencoded") + assert request.headers["authorization"].startswith("Basic ") + assert form == {"grant_type": ["refresh_token"], "refresh_token": [refresh_token]} + assert "client_id" not in form + assert "client_secret" not in form + return httpx.Response( + 200, + json={ + "token_type": "bearer", + "access_token": returned_access_token, + "refresh_token": returned_refresh_token, + "expires_in": 1800, + }, + ) + + provider = HttpxYandexOAuthProvider( + config=config, + transport=httpx.MockTransport(handler), + ) + + tokens = provider.refresh_tokens(refresh_token=refresh_token) + + assert len(requests) == 1 + assert tokens.expires_in == 1800 + assert "access_token=" not in repr(tokens) + assert "refresh_token=" not in repr(tokens) + + def _contains_any(value: str, markers: tuple[str, ...]) -> bool: return any(marker in value for marker in markers) diff --git a/tests/test_yandex_refresh.py b/tests/test_yandex_refresh.py new file mode 100644 index 0000000..a796a3a --- /dev/null +++ b/tests/test_yandex_refresh.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from app.modules.integrations.yandex.refresh import ( + DEFAULT_REFRESH_SKEW_SECONDS, + is_refresh_due, +) + + +def test_refresh_is_due_at_the_exact_default_skew_boundary() -> None: + expires_at = datetime(2026, 9, 6, 12, tzinfo=timezone.utc) + + assert DEFAULT_REFRESH_SKEW_SECONDS == 60 + assert not is_refresh_due( + now=expires_at - timedelta(seconds=DEFAULT_REFRESH_SKEW_SECONDS + 1), + expires_at=expires_at, + skew_seconds=DEFAULT_REFRESH_SKEW_SECONDS, + ) + assert is_refresh_due( + now=expires_at - timedelta(seconds=DEFAULT_REFRESH_SKEW_SECONDS), + expires_at=expires_at, + skew_seconds=DEFAULT_REFRESH_SKEW_SECONDS, + ) From b75122e82a212cf590298caa9e08ee70f82060ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 10:24:21 +0300 Subject: [PATCH 4/8] feat(yandex): add secure account discovery --- .../20260906_0010_yandex_provider_accounts.py | 144 +++++ app/db/models.py | 62 ++ .../integrations/yandex/account_provider.py | 218 +++++++ app/modules/integrations/yandex/accounts.py | 252 ++++++++ .../integrations/yandex/credentials.py | 196 ++++++ app/modules/integrations/yandex/repository.py | 228 +++++++ .../test_yandex_account_reconciliation.py | 564 ++++++++++++++++++ tests/integration/test_yandex_accounts.py | 222 +++++++ tests/test_yandex_account_classification.py | 272 +++++++++ tests/test_yandex_account_credentials.py | 60 ++ tests/test_yandex_account_discovery.py | 128 ++++ .../test_yandex_account_provider_failures.py | 241 ++++++++ tests/test_yandex_accounts.py | 225 +++++++ 13 files changed, 2812 insertions(+) create mode 100644 app/db/migrations/versions/20260906_0010_yandex_provider_accounts.py create mode 100644 app/modules/integrations/yandex/account_provider.py create mode 100644 app/modules/integrations/yandex/accounts.py create mode 100644 tests/integration/test_yandex_account_reconciliation.py create mode 100644 tests/integration/test_yandex_accounts.py create mode 100644 tests/test_yandex_account_classification.py create mode 100644 tests/test_yandex_account_credentials.py create mode 100644 tests/test_yandex_account_discovery.py create mode 100644 tests/test_yandex_account_provider_failures.py create mode 100644 tests/test_yandex_accounts.py diff --git a/app/db/migrations/versions/20260906_0010_yandex_provider_accounts.py b/app/db/migrations/versions/20260906_0010_yandex_provider_accounts.py new file mode 100644 index 0000000..bb92d52 --- /dev/null +++ b/app/db/migrations/versions/20260906_0010_yandex_provider_accounts.py @@ -0,0 +1,144 @@ +"""P4 protected, workspace-scoped Yandex provider accounts.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260906_0010" +down_revision = "20260906_0009" +branch_labels = None +depends_on = None + +_WORKSPACE_SETTING = "NULLIF(current_setting('app.current_workspace_id', true), '')::uuid" +_USER_SETTING = "NULLIF(current_setting('app.current_user_id', true), '')::uuid" +_MEMBERSHIP_CHECK = f"public.directpilot_has_active_membership({_WORKSPACE_SETTING}, {_USER_SETTING})" + + +def _enable_provider_account_rls() -> None: + connection_scope = ( + "EXISTS (SELECT 1 FROM yandex_provider_connections " + "WHERE yandex_provider_connections.id = provider_accounts.connection_id " + "AND yandex_provider_connections.workspace_id = provider_accounts.workspace_id " + "AND yandex_provider_connections.status = 'active')" + ) + predicate = ( + f"workspace_id = {_WORKSPACE_SETTING} AND {_MEMBERSHIP_CHECK} " + f"AND {connection_scope}" + ) + op.execute("ALTER TABLE provider_accounts ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE provider_accounts FORCE ROW LEVEL SECURITY") + op.execute( + "CREATE POLICY provider_accounts_workspace_isolation " + "ON provider_accounts FOR ALL TO directpilot_app " + f"USING ({predicate}) WITH CHECK ({predicate})" + ) + + +def upgrade() -> None: + op.create_unique_constraint( + op.f("uq_yandex_provider_connections_id"), + "yandex_provider_connections", + ["id", "workspace_id"], + ) + op.create_table( + "provider_accounts", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("connection_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("provider_account_key", sa.String(length=255), nullable=False), + sa.Column("account_type", sa.String(length=32), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("capabilities", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("country_id", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(length=16), nullable=True), + sa.Column("login_ciphertext", sa.LargeBinary(), nullable=False), + sa.Column("login_nonce", sa.LargeBinary(length=12), nullable=False), + sa.Column("login_wrapped_dek", sa.LargeBinary(), nullable=False), + sa.Column("login_wrap_nonce", sa.LargeBinary(length=12), nullable=False), + sa.Column("login_kek_key_id", sa.String(length=128), nullable=False), + sa.Column("login_schema_version", sa.Integer(), nullable=False), + sa.Column("last_discovered_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.CheckConstraint( + "account_type IN ('advertiser', 'agency_client')", + name=op.f("ck_provider_accounts_account_type_valid"), + ), + sa.CheckConstraint( + "status IN ('active', 'archived', 'stale')", + name=op.f("ck_provider_accounts_status_valid"), + ), + sa.CheckConstraint( + "capabilities = '[\"direct.read\"]'::jsonb", + name=op.f("ck_provider_accounts_capabilities_read_only"), + ), + sa.CheckConstraint( + "login_schema_version > 0", + name=op.f("ck_provider_accounts_login_schema_version_positive"), + ), + sa.CheckConstraint( + "version > 0", + name=op.f("ck_provider_accounts_version_positive"), + ), + sa.CheckConstraint( + "octet_length(login_nonce) = 12", + name=op.f("ck_provider_accounts_login_nonce_length"), + ), + sa.CheckConstraint( + "octet_length(login_wrap_nonce) = 12", + name=op.f("ck_provider_accounts_login_wrap_nonce_length"), + ), + sa.CheckConstraint( + "char_length(login_kek_key_id) > 0", + name=op.f("ck_provider_accounts_login_kek_key_id_not_empty"), + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.id"], + name=op.f("fk_provider_accounts_workspace_id_workspaces"), + ), + sa.ForeignKeyConstraint( + ["connection_id", "workspace_id"], + ["yandex_provider_connections.id", "yandex_provider_connections.workspace_id"], + name=op.f("fk_provider_accounts_connection_id_yandex_provider_connections"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_provider_accounts")), + sa.UniqueConstraint( + "connection_id", + "provider_account_key", + name=op.f("uq_provider_accounts_connection_id"), + ), + ) + op.create_index( + op.f("ix_provider_accounts_workspace_id"), + "provider_accounts", + ["workspace_id"], + ) + op.create_index( + op.f("ix_provider_accounts_connection_id"), + "provider_accounts", + ["connection_id"], + ) + op.execute("REVOKE ALL ON TABLE provider_accounts FROM PUBLIC") + op.execute("GRANT SELECT, INSERT, UPDATE ON TABLE provider_accounts TO directpilot_app") + _enable_provider_account_rls() + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS provider_accounts_workspace_isolation ON provider_accounts") + op.execute("ALTER TABLE provider_accounts NO FORCE ROW LEVEL SECURITY") + op.execute("ALTER TABLE provider_accounts DISABLE ROW LEVEL SECURITY") + op.drop_index(op.f("ix_provider_accounts_connection_id"), table_name="provider_accounts") + op.drop_index(op.f("ix_provider_accounts_workspace_id"), table_name="provider_accounts") + op.drop_table("provider_accounts") + op.drop_constraint( + op.f("uq_yandex_provider_connections_id"), + "yandex_provider_connections", + type_="unique", + ) diff --git a/app/db/models.py b/app/db/models.py index 444c33b..4720448 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -9,6 +9,7 @@ CheckConstraint, DateTime, ForeignKey, + ForeignKeyConstraint, Integer, LargeBinary, MetaData, @@ -206,6 +207,7 @@ class YandexProviderConnectionRecord(Base): __tablename__ = "yandex_provider_connections" __table_args__ = ( UniqueConstraint("workspace_id", "provider", "external_identity_id"), + UniqueConstraint("id", "workspace_id"), CheckConstraint("provider = 'yandex'", name="provider_yandex"), CheckConstraint("schema_version > 0", name="schema_version_positive"), CheckConstraint("version > 0", name="version_positive"), @@ -240,6 +242,66 @@ class YandexProviderConnectionRecord(Base): version: Mapped[int] = mapped_column(Integer, nullable=False) +class ProviderAccountRecord(Base): + __tablename__ = "provider_accounts" + __table_args__ = ( + UniqueConstraint("connection_id", "provider_account_key"), + ForeignKeyConstraint( + ["connection_id", "workspace_id"], + ["yandex_provider_connections.id", "yandex_provider_connections.workspace_id"], + ondelete="CASCADE", + ), + CheckConstraint( + "account_type IN ('advertiser', 'agency_client')", + name="account_type_valid", + ), + CheckConstraint( + "status IN ('active', 'archived', 'stale')", + name="status_valid", + ), + CheckConstraint( + "capabilities = '[\"direct.read\"]'::jsonb", + name="capabilities_read_only", + ), + CheckConstraint("login_schema_version > 0", name="login_schema_version_positive"), + CheckConstraint("version > 0", name="version_positive"), + CheckConstraint("octet_length(login_nonce) = 12", name="login_nonce_length"), + CheckConstraint("octet_length(login_wrap_nonce) = 12", name="login_wrap_nonce_length"), + CheckConstraint("char_length(login_kek_key_id) > 0", name="login_kek_key_id_not_empty"), + ) + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + workspace_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + nullable=False, + index=True, + ) + connection_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + nullable=False, + index=True, + ) + provider_account_key: Mapped[str] = mapped_column(String(255), nullable=False) + account_type: Mapped[str] = mapped_column(String(32), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + capabilities: Mapped[list[str]] = mapped_column(JSONB, nullable=False) + country_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + currency: Mapped[str | None] = mapped_column(String(16), nullable=True) + login_ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + login_nonce: Mapped[bytes] = mapped_column(LargeBinary(12), nullable=False) + login_wrapped_dek: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + login_wrap_nonce: Mapped[bytes] = mapped_column(LargeBinary(12), nullable=False) + login_kek_key_id: Mapped[str] = mapped_column(String(128), nullable=False) + login_schema_version: Mapped[int] = mapped_column(Integer, nullable=False) + last_discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_verified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False) + + class AuditEventRecord(Base): __tablename__ = "audit_events" diff --git a/app/modules/integrations/yandex/account_provider.py b/app/modules/integrations/yandex/account_provider.py new file mode 100644 index 0000000..7f10be1 --- /dev/null +++ b/app/modules/integrations/yandex/account_provider.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import json +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + +import httpx + +_DIRECT_API_BASE_URL = "https://api.direct.yandex.com/json/v5" +_DISCOVERY_TIMEOUT_SECONDS = 10.0 +_RETRY_DELAY_SECONDS = 0.1 +_RETRYABLE_DIRECT_CODES = frozenset({52, 506, 1000}) +_CLIENTS_FIELD_NAMES = ( + "ClientId", + "Login", + "ClientInfo", + "Type", + "Archived", + "CountryId", + "Currency", + "Grants", + "Representatives", +) +_AGENCY_CLIENTS_FIELD_NAMES = ( + "ClientId", + "Login", + "ClientInfo", + "Archived", + "CountryId", + "Currency", + "Grants", + "Representatives", + "Type", +) + + +class DirectAccountDiscoveryProviderFailure(RuntimeError): + """Safe failure from the Direct account-discovery HTTP boundary.""" + + def __init__(self, kind: str, *, direct_code: int | None = None) -> None: + self.kind = kind + self.direct_code = direct_code + super().__init__("Direct account discovery provider request failed") + + +@dataclass(frozen=True, slots=True) +class DirectAccountDiscoveryPage: + """Internal provider page; provider data stays out of repr output.""" + + client_rows: tuple[Mapping[str, Any], ...] = field(repr=False) + limited_by: int | None = None + + +class HttpxYandexDirectAccountProvider: + """Fixed, read-only Direct v5 account-discovery boundary.""" + + def __init__( + self, + *, + access_token: str, + transport: httpx.BaseTransport | None = None, + sleeper: Callable[[float], None] | None = None, + ) -> None: + if not isinstance(access_token, str) or not access_token: + raise ValueError("Direct discovery access token is invalid") + self._access_token = access_token + self._transport = transport + self._sleeper = time.sleep if sleeper is None else sleeper + + def __repr__(self) -> str: + return "HttpxYandexDirectAccountProvider()" + + def clients_get(self) -> DirectAccountDiscoveryPage: + return self._post( + path="/clients", + payload={ + "method": "get", + "params": {"FieldNames": list(_CLIENTS_FIELD_NAMES)}, + }, + ) + + def agencyclients_get(self, *, offset: int) -> DirectAccountDiscoveryPage: + if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: + raise ValueError("Direct agency discovery offset is invalid") + return self._post( + path="/agencyclients", + payload={ + "method": "get", + "params": { + "SelectionCriteria": {}, + "FieldNames": list(_AGENCY_CLIENTS_FIELD_NAMES), + "Page": {"Limit": 10_000, "Offset": offset}, + }, + }, + ) + + def _post(self, *, path: str, payload: Mapping[str, object]) -> DirectAccountDiscoveryPage: + for attempt in range(2): + try: + response = self._send(path=path, payload=payload) + except httpx.HTTPError: + if attempt == 0: + self._sleeper(_RETRY_DELAY_SECONDS) + continue + raise DirectAccountDiscoveryProviderFailure("provider_unavailable") from None + + if 500 <= response.status_code <= 599: + if attempt == 0: + self._sleeper(_RETRY_DELAY_SECONDS) + continue + raise DirectAccountDiscoveryProviderFailure("provider_unavailable") + + document = _strict_json_mapping(response) + error_code = _direct_error_code(document) + if error_code is not None: + if error_code in _RETRYABLE_DIRECT_CODES and attempt == 0: + self._sleeper(_RETRY_DELAY_SECONDS) + continue + failure_kind = ( + "provider_temporary" + if error_code in _RETRYABLE_DIRECT_CODES + else "provider_error" + ) + raise DirectAccountDiscoveryProviderFailure( + failure_kind, + direct_code=error_code, + ) + if response.status_code != 200: + raise DirectAccountDiscoveryProviderFailure("provider_http_error") + return _success_page(document) + raise AssertionError("unreachable retry state") + + def _send(self, *, path: str, payload: Mapping[str, object]) -> httpx.Response: + with httpx.Client( + timeout=httpx.Timeout(_DISCOVERY_TIMEOUT_SECONDS), + transport=self._transport, + ) as client: + return client.post( + f"{_DIRECT_API_BASE_URL}{path}", + json=payload, + headers={ + "Authorization": f"Bearer {self._access_token}", + "Content-Type": "application/json; charset=utf-8", + "Accept-Language": "en", + }, + ) + + +class _DuplicateJsonKey(ValueError): + pass + + +class _NonRfcJsonConstant(ValueError): + pass + + +def _reject_non_rfc_json_constant(value: str) -> object: + del value + raise _NonRfcJsonConstant + + +def _strict_json_mapping(response: httpx.Response) -> Mapping[str, Any]: + try: + document = json.loads( + response.content.decode("utf-8"), + object_pairs_hook=_json_object_without_duplicates, + parse_constant=_reject_non_rfc_json_constant, + ) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + _DuplicateJsonKey, + _NonRfcJsonConstant, + ): + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") from None + if not isinstance(document, Mapping): + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") + return document + + +def _json_object_without_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]: + document: dict[str, object] = {} + for key, value in pairs: + if key in document: + raise _DuplicateJsonKey + document[key] = value + return document + + +def _direct_error_code(document: Mapping[str, Any]) -> int | None: + if "error" not in document: + return None + if "result" in document: + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") + error = document["error"] + if not isinstance(error, Mapping): + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") + error_code = error.get("error_code") + if not isinstance(error_code, int) or isinstance(error_code, bool): + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") + return error_code + + +def _success_page(document: Mapping[str, Any]) -> DirectAccountDiscoveryPage: + result = document.get("result") + if not isinstance(result, Mapping): + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") + clients = result.get("Clients") + if not isinstance(clients, list) or not all(isinstance(row, Mapping) for row in clients): + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") + limited_by: int | None = None + if "LimitedBy" in result: + limited_by = result["LimitedBy"] + if not isinstance(limited_by, int) or isinstance(limited_by, bool): + raise DirectAccountDiscoveryProviderFailure("provider_malformed_response") + return DirectAccountDiscoveryPage(client_rows=tuple(clients), limited_by=limited_by) diff --git a/app/modules/integrations/yandex/accounts.py b/app/modules/integrations/yandex/accounts.py new file mode 100644 index 0000000..a774dd1 --- /dev/null +++ b/app/modules/integrations/yandex/accounts.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Literal, Protocol +from uuid import UUID + +from app.modules.integrations.yandex.account_provider import ( + DirectAccountDiscoveryPage, + DirectAccountDiscoveryProviderFailure, +) + +AccountType = Literal["advertiser", "agency_client"] +AccountStatus = Literal["active", "archived", "stale"] + + +class AccountDiscoveryFailure(RuntimeError): + """Safe failure from account discovery; provider data is intentionally omitted.""" + + def __init__(self, kind: str, *, direct_code: int | None = None) -> None: + self.kind = kind + self.direct_code = direct_code + super().__init__("Yandex account discovery failed") + + +@dataclass(frozen=True, slots=True) +class ValidatedProviderAccount: + """Validated internal snapshot row; routing Login cannot appear in repr output.""" + + provider_account_key: str + routing_login: str = field(repr=False) + display_name: str + account_type: AccountType + status: Literal["active", "archived"] + country_id: int | None + currency: str | None + capabilities: tuple[str, ...] = field(default=("direct.read",), init=False) + + +@dataclass(frozen=True, slots=True) +class DiscoveredProviderAccount: + """Safe account result for internal callers; no provider key or Login is exposed.""" + + id: UUID + workspace_id: UUID + connection_id: UUID + display_name: str + account_type: AccountType + status: AccountStatus + capabilities: tuple[str, ...] + country_id: int | None + currency: str | None + last_discovered_at: datetime + last_verified_at: datetime + + +class AccountDiscoveryProvider(Protocol): + def clients_get(self) -> DirectAccountDiscoveryPage: ... + + def agencyclients_get(self, *, offset: int) -> DirectAccountDiscoveryPage: ... + + +class ProviderAccountDiscoveryRepository(Protocol): + def begin_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + ) -> int: ... + + def reconcile_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + expected_connection_version: int, + candidates: tuple[ValidatedProviderAccount, ...], + now: datetime, + ) -> tuple[DiscoveredProviderAccount, ...]: ... + + +class AccountDiscoveryService: + """Fetches a complete account snapshot before entering a reconciliation transaction.""" + + def __init__(self, *, repository: ProviderAccountDiscoveryRepository) -> None: + self._repository = repository + + def discover( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + provider: AccountDiscoveryProvider, + now: datetime | None = None, + ) -> tuple[DiscoveredProviderAccount, ...]: + observed_at = _utc_now(now) + expected_connection_version = self._repository.begin_discovery( + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + ) + try: + holder_page = provider.clients_get() + candidates = _discover_snapshot(provider=provider, holder_page=holder_page) + except DirectAccountDiscoveryProviderFailure as exc: + raise AccountDiscoveryFailure(exc.kind, direct_code=exc.direct_code) from None + return self._repository.reconcile_discovery( + user_id=user_id, + workspace_id=workspace_id, + connection_id=connection_id, + expected_connection_version=expected_connection_version, + candidates=candidates, + now=observed_at, + ) + + +def _discover_snapshot( + *, + provider: AccountDiscoveryProvider, + holder_page: DirectAccountDiscoveryPage, +) -> tuple[ValidatedProviderAccount, ...]: + if holder_page.limited_by is not None or len(holder_page.client_rows) != 1: + raise AccountDiscoveryFailure("provider_malformed_response") + holder = holder_page.client_rows[0] + holder_type = _required_text(holder, "Type") + if holder_type == "CLIENT": + return (_validated_candidate(holder, account_type="advertiser"),) + if holder_type == "SUBCLIENT": + return (_validated_candidate(holder, account_type="agency_client"),) + if holder_type != "AGENCY": + raise AccountDiscoveryFailure("provider_malformed_response") + _validated_candidate(holder, account_type="advertiser") + return _agency_candidates(provider=provider) + + +def _agency_candidates( + *, + provider: AccountDiscoveryProvider, +) -> tuple[ValidatedProviderAccount, ...]: + offset = 0 + candidates: list[ValidatedProviderAccount] = [] + seen_keys: set[str] = set() + login_to_key: dict[str, str] = {} + while True: + try: + page = provider.agencyclients_get(offset=offset) + except DirectAccountDiscoveryProviderFailure as exc: + raise AccountDiscoveryFailure(exc.kind, direct_code=exc.direct_code) from None + for row in page.client_rows: + if _required_text(row, "Type") != "SUBCLIENT": + raise AccountDiscoveryFailure("provider_malformed_response") + candidate = _validated_candidate(row, account_type="agency_client") + if candidate.provider_account_key in seen_keys: + raise AccountDiscoveryFailure("provider_malformed_response") + prior_key = login_to_key.setdefault( + candidate.routing_login, + candidate.provider_account_key, + ) + if prior_key != candidate.provider_account_key: + raise AccountDiscoveryFailure("provider_malformed_response") + seen_keys.add(candidate.provider_account_key) + candidates.append(candidate) + limited_by = page.limited_by + if limited_by is None: + return tuple(candidates) + if ( + not isinstance(limited_by, int) + or isinstance(limited_by, bool) + or limited_by <= offset + or not page.client_rows + ): + raise AccountDiscoveryFailure("provider_malformed_response") + offset = limited_by + + +def _validated_candidate( + row: object, + *, + account_type: AccountType, +) -> ValidatedProviderAccount: + if not isinstance(row, dict): + raise AccountDiscoveryFailure("provider_malformed_response") + client_id = row.get("ClientId") + if not isinstance(client_id, int) or isinstance(client_id, bool) or client_id <= 0: + raise AccountDiscoveryFailure("provider_malformed_response") + login = _required_text(row, "Login") + archived = _required_text(row, "Archived") + if archived == "NO": + status: Literal["active", "archived"] = "active" + elif archived == "YES": + status = "archived" + else: + raise AccountDiscoveryFailure("provider_malformed_response") + provider_account_key = str(client_id) + display_name = _display_name(row, provider_account_key=provider_account_key) + return ValidatedProviderAccount( + provider_account_key=provider_account_key, + routing_login=login, + display_name=display_name, + account_type=account_type, + status=status, + country_id=_optional_country_id(row.get("CountryId")), + currency=_optional_currency(row.get("Currency")), + ) + + +def _required_text(row: object, field_name: str) -> str: + if not isinstance(row, dict): + raise AccountDiscoveryFailure("provider_malformed_response") + value = row.get(field_name) + if not isinstance(value, str) or not value.strip(): + raise AccountDiscoveryFailure("provider_malformed_response") + return value + + +def _display_name(row: dict[str, object], *, provider_account_key: str) -> str: + value = row.get("ClientInfo") + if value is None: + return _fallback_display_name(provider_account_key) + if not isinstance(value, str): + raise AccountDiscoveryFailure("provider_malformed_response") + return value if value.strip() else _fallback_display_name(provider_account_key) + + +def _fallback_display_name(provider_account_key: str) -> str: + return f"Yandex Direct account {provider_account_key}"[:255] + + +def _optional_country_id(value: object) -> int | None: + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise AccountDiscoveryFailure("provider_malformed_response") + return value + + +def _optional_currency(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise AccountDiscoveryFailure("provider_malformed_response") + return value + + +def _utc_now(value: datetime | None) -> datetime: + now = datetime.now(timezone.utc) if value is None else value + if now.tzinfo is None: + raise ValueError("Account discovery time must be timezone-aware") + return now.astimezone(timezone.utc) diff --git a/app/modules/integrations/yandex/credentials.py b/app/modules/integrations/yandex/credentials.py index f3daf58..b77af71 100644 --- a/app/modules/integrations/yandex/credentials.py +++ b/app/modules/integrations/yandex/credentials.py @@ -19,6 +19,8 @@ YANDEX_PROVIDER = "yandex" _TOKEN_PAYLOAD_PURPOSE = "yandex-token-payload" _DEK_WRAP_PURPOSE = "yandex-dek-wrap" +_PROVIDER_ACCOUNT_LOGIN_PURPOSE = "yandex-provider-account-login" +_PROVIDER_ACCOUNT_LOGIN_WRAP_PURPOSE = "yandex-provider-account-login-dek-wrap" _NONCE_LENGTH = 12 _DEK_LENGTH = 32 _KEY_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") @@ -79,6 +81,66 @@ def __post_init__(self) -> None: raise CredentialConfigurationError("Credential context is invalid") +@dataclass(frozen=True, slots=True) +class ProviderAccountLoginContext: + """Non-secret identifiers bound to one protected provider routing Login.""" + + workspace_id: UUID + connection_id: UUID + provider_account_id: UUID + schema_version: int = CREDENTIAL_SCHEMA_VERSION + provider: str = YANDEX_PROVIDER + login_purpose: str = _PROVIDER_ACCOUNT_LOGIN_PURPOSE + wrap_purpose: str = _PROVIDER_ACCOUNT_LOGIN_WRAP_PURPOSE + + @classmethod + def for_yandex_provider_account( + cls, + *, + workspace_id: UUID, + connection_id: UUID, + provider_account_id: UUID, + ) -> ProviderAccountLoginContext: + return cls( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=provider_account_id, + ) + + def __post_init__(self) -> None: + if self.schema_version <= 0: + raise CredentialConfigurationError("Provider account login context is invalid") + if not self.provider or not self.provider.isascii() or len(self.provider) > 32: + raise CredentialConfigurationError("Provider account login context is invalid") + for purpose in (self.login_purpose, self.wrap_purpose): + if not purpose or not purpose.isascii() or len(purpose) > 128: + raise CredentialConfigurationError("Provider account login context is invalid") + + +@dataclass(frozen=True, slots=True) +class EncryptedProviderAccountLogin: + """Persistable account Login envelope; encrypted material is never repr'd.""" + + ciphertext: bytes = field(repr=False) + nonce: bytes = field(repr=False) + wrapped_dek: bytes = field(repr=False) + wrap_nonce: bytes = field(repr=False) + kek_key_id: str + schema_version: int + + def __post_init__(self) -> None: + if self.schema_version <= 0 or not _is_valid_key_id(self.kek_key_id): + raise CredentialDecryptionError("Encrypted provider account Login metadata is invalid") + if not isinstance(self.ciphertext, bytes) or not self.ciphertext: + raise CredentialDecryptionError("Encrypted provider account Login metadata is invalid") + if not isinstance(self.wrapped_dek, bytes) or not self.wrapped_dek: + raise CredentialDecryptionError("Encrypted provider account Login metadata is invalid") + if not isinstance(self.nonce, bytes) or len(self.nonce) != _NONCE_LENGTH: + raise CredentialDecryptionError("Encrypted provider account Login metadata is invalid") + if not isinstance(self.wrap_nonce, bytes) or len(self.wrap_nonce) != _NONCE_LENGTH: + raise CredentialDecryptionError("Encrypted provider account Login metadata is invalid") + + @dataclass(frozen=True, slots=True) class YandexCredentialPayload: """Opaque plaintext token value held only in the bounded provider-call scope.""" @@ -220,6 +282,71 @@ class CredentialVault: def __init__(self, key_ring: CredentialKeyRing) -> None: self._key_ring = key_ring + def encrypt_provider_account_login( + self, + *, + context: ProviderAccountLoginContext, + login: str, + ) -> EncryptedProviderAccountLogin: + if context.schema_version != CREDENTIAL_SCHEMA_VERSION or context.provider != YANDEX_PROVIDER: + raise CredentialEncryptionError("Provider account Login context is invalid") + plaintext = _serialize_provider_account_login(login) + dek = os.urandom(_DEK_LENGTH) + try: + nonce = os.urandom(_NONCE_LENGTH) + wrap_nonce = os.urandom(_NONCE_LENGTH) + key_id, kek = self._key_ring.active_key() + ciphertext = AESGCM(dek).encrypt( + nonce, + plaintext, + _provider_account_login_authenticated_data(context, context.login_purpose), + ) + wrapped_dek = AESGCM(kek).encrypt( + wrap_nonce, + dek, + _provider_account_login_authenticated_data( + context, + context.wrap_purpose, + kek_key_id=key_id, + ), + ) + return EncryptedProviderAccountLogin( + ciphertext=ciphertext, + nonce=nonce, + wrapped_dek=wrapped_dek, + wrap_nonce=wrap_nonce, + kek_key_id=key_id, + schema_version=context.schema_version, + ) + except CredentialVaultError: + raise + except (TypeError, ValueError): + raise CredentialEncryptionError("Provider account Login encryption failed") from None + finally: + del plaintext + del dek + + def decrypt_provider_account_login( + self, + *, + context: ProviderAccountLoginContext, + encrypted: EncryptedProviderAccountLogin, + ) -> str: + dek = self._unwrap_provider_account_login_dek(context=context, encrypted=encrypted) + try: + plaintext = AESGCM(dek).decrypt( + encrypted.nonce, + encrypted.ciphertext, + _provider_account_login_authenticated_data(context, context.login_purpose), + ) + return _deserialize_provider_account_login(plaintext) + except CredentialVaultError: + raise + except (InvalidTag, UnicodeDecodeError, ValueError): + raise CredentialDecryptionError("Provider account Login decryption failed") from None + finally: + del dek + def encrypt( self, *, @@ -318,6 +445,35 @@ def rewrap_to_active_key( finally: del dek + def _unwrap_provider_account_login_dek( + self, + *, + context: ProviderAccountLoginContext, + encrypted: EncryptedProviderAccountLogin, + ) -> bytes: + if ( + context.schema_version != CREDENTIAL_SCHEMA_VERSION + or context.provider != YANDEX_PROVIDER + or encrypted.schema_version != context.schema_version + ): + raise CredentialDecryptionError("Encrypted provider account Login metadata is invalid") + kek = self._key_ring.key_for(encrypted.kek_key_id) + try: + dek = AESGCM(kek).decrypt( + encrypted.wrap_nonce, + encrypted.wrapped_dek, + _provider_account_login_authenticated_data( + context, + context.wrap_purpose, + kek_key_id=encrypted.kek_key_id, + ), + ) + except (InvalidTag, ValueError): + raise CredentialDecryptionError("Provider account Login decryption failed") from None + if len(dek) != _DEK_LENGTH: + raise CredentialDecryptionError("Provider account Login decryption failed") + return dek + def _unwrap_dek( self, *, @@ -456,6 +612,46 @@ def _required_timestamp(value: object) -> datetime: return parsed +def _serialize_provider_account_login(value: str) -> bytes: + if not isinstance(value, str) or not value.strip() or len(value) > 1024: + raise CredentialPayloadError("Provider account Login is invalid") + try: + return value.encode("utf-8") + except UnicodeEncodeError: + raise CredentialPayloadError("Provider account Login is invalid") from None + + +def _deserialize_provider_account_login(value: bytes) -> str: + try: + login = value.decode("utf-8") + except UnicodeDecodeError: + raise CredentialDecryptionError("Provider account Login is invalid") from None + try: + _serialize_provider_account_login(login) + except CredentialPayloadError: + raise CredentialDecryptionError("Provider account Login is invalid") from None + return login + + +def _provider_account_login_authenticated_data( + context: ProviderAccountLoginContext, + purpose: str, + *, + kek_key_id: str | None = None, +) -> bytes: + document: dict[str, int | str] = { + "connection_id": str(context.connection_id), + "provider": context.provider, + "provider_account_id": str(context.provider_account_id), + "purpose": purpose, + "schema_version": context.schema_version, + "workspace_id": str(context.workspace_id), + } + if kek_key_id is not None: + document["kek_key_id"] = kek_key_id + return json.dumps(document, separators=(",", ":"), sort_keys=True).encode("ascii") + + def _authenticated_data( context: CredentialContext, purpose: str, diff --git a/app/modules/integrations/yandex/repository.py b/app/modules/integrations/yandex/repository.py index 05fbc48..5a1ae6f 100644 --- a/app/modules/integrations/yandex/repository.py +++ b/app/modules/integrations/yandex/repository.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone +from typing import cast from uuid import UUID, uuid4 from sqlalchemy import select @@ -12,16 +13,25 @@ AuditEventRecord, ExternalIdentityRecord, OAuthTransactionRecord, + ProviderAccountRecord, YandexProviderConnectionRecord, ) from app.db.rls import tenant_transaction from app.modules.audit.repository import sanitize_audit_metadata +from app.modules.integrations.yandex.accounts import ( + AccountStatus, + AccountType, + DiscoveredProviderAccount, + ValidatedProviderAccount, +) from app.modules.integrations.yandex.credentials import ( CredentialContext, CredentialPayloadError, CredentialVault, CredentialVaultError, + EncryptedProviderAccountLogin, EncryptedYandexCredential, + ProviderAccountLoginContext, YandexCredentialPayload, ) from app.modules.integrations.yandex.oauth import ( @@ -210,6 +220,14 @@ class ProviderConnectionPersistenceError(RuntimeError): """Safe failure while persisting or rotating an encrypted provider connection.""" +class ProviderAccountDiscoveryUnavailable(RuntimeError): + """A same-workspace active Yandex connection was unavailable for discovery.""" + + +class ProviderAccountPersistenceError(RuntimeError): + """Safe failure while atomically reconciling protected provider accounts.""" + + @dataclass(frozen=True, slots=True) class YandexProviderConnection: """Safe connection metadata; encrypted fields and tokens are intentionally omitted.""" @@ -580,6 +598,216 @@ def rewrap_to_active_key( return _safe_provider_connection(record) +class PostgresYandexProviderAccountRepository: + """Reconciles complete, validated Direct account snapshots under tenant RLS.""" + + def __init__(self, sessions: sessionmaker[Session], *, vault: CredentialVault) -> None: + self._sessions = sessions + self._vault = vault + + def begin_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + ) -> int: + try: + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + connection = session.scalar( + select(YandexProviderConnectionRecord).where( + YandexProviderConnectionRecord.id == connection_id, + YandexProviderConnectionRecord.workspace_id == workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + ) + ) + if connection is None or connection.status != "active": + raise ProviderAccountDiscoveryUnavailable( + "Yandex provider connection is unavailable" + ) + return connection.version + except ProviderAccountDiscoveryUnavailable: + raise + except SQLAlchemyError: + raise ProviderAccountPersistenceError( + "Yandex provider accounts could not be loaded" + ) from None + + def reconcile_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + expected_connection_version: int, + candidates: tuple[ValidatedProviderAccount, ...], + now: datetime, + ) -> tuple[DiscoveredProviderAccount, ...]: + _validate_account_snapshot(candidates) + observed_at = _utc_now(now) + try: + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + connection = session.scalar( + select(YandexProviderConnectionRecord) + .where( + YandexProviderConnectionRecord.id == connection_id, + YandexProviderConnectionRecord.workspace_id == workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + ) + .with_for_update() + ) + if ( + connection is None + or connection.status != "active" + or connection.version != expected_connection_version + ): + raise ProviderAccountDiscoveryUnavailable( + "Yandex provider connection is unavailable" + ) + existing_records = list( + session.scalars( + select(ProviderAccountRecord) + .where( + ProviderAccountRecord.workspace_id == workspace_id, + ProviderAccountRecord.connection_id == connection_id, + ) + .with_for_update() + ) + ) + existing_by_key = { + record.provider_account_key: record for record in existing_records + } + if len(existing_by_key) != len(existing_records): + raise ProviderAccountPersistenceError( + "Yandex provider accounts could not be reconciled" + ) + reconciled_by_key: dict[str, ProviderAccountRecord] = {} + for candidate in candidates: + record = existing_by_key.get(candidate.provider_account_key) + is_existing = record is not None + if record is None: + record = ProviderAccountRecord( + id=uuid4(), + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_key=candidate.provider_account_key, + account_type=candidate.account_type, + display_name=candidate.display_name, + status=candidate.status, + capabilities=["direct.read"], + country_id=candidate.country_id, + currency=candidate.currency, + login_ciphertext=b"pending", + login_nonce=b"0" * 12, + login_wrapped_dek=b"pending", + login_wrap_nonce=b"0" * 12, + login_kek_key_id="pending", + login_schema_version=1, + last_discovered_at=observed_at, + last_verified_at=observed_at, + created_at=observed_at, + updated_at=observed_at, + version=1, + ) + session.add(record) + encrypted = self._vault.encrypt_provider_account_login( + context=ProviderAccountLoginContext.for_yandex_provider_account( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=record.id, + ), + login=candidate.routing_login, + ) + _replace_protected_provider_account_login(record, encrypted) + record.account_type = candidate.account_type + record.display_name = candidate.display_name + record.status = candidate.status + record.capabilities = ["direct.read"] + record.country_id = candidate.country_id + record.currency = candidate.currency + record.last_discovered_at = observed_at + record.last_verified_at = observed_at + record.updated_at = observed_at + if is_existing: + record.version += 1 + reconciled_by_key[candidate.provider_account_key] = record + returned_keys = set(reconciled_by_key) + for record in existing_records: + if record.provider_account_key not in returned_keys and record.status == "active": + record.status = "stale" + record.updated_at = observed_at + record.version += 1 + connection.updated_at = observed_at + connection.version += 1 + session.flush() + return tuple( + _safe_provider_account(reconciled_by_key[candidate.provider_account_key]) + for candidate in candidates + ) + except (ProviderAccountDiscoveryUnavailable, ProviderAccountPersistenceError): + raise + except CredentialVaultError: + raise ProviderAccountPersistenceError( + "Yandex provider accounts could not be reconciled" + ) from None + except (IntegrityError, SQLAlchemyError): + raise ProviderAccountPersistenceError( + "Yandex provider accounts could not be reconciled" + ) from None + + +def _validate_account_snapshot(candidates: tuple[ValidatedProviderAccount, ...]) -> None: + keys: set[str] = set() + for candidate in candidates: + if ( + not candidate.provider_account_key + or candidate.provider_account_key in keys + or candidate.capabilities != ("direct.read",) + or candidate.account_type not in {"advertiser", "agency_client"} + or candidate.status not in {"active", "archived"} + ): + raise ProviderAccountPersistenceError( + "Yandex provider account snapshot is invalid" + ) + keys.add(candidate.provider_account_key) + + +def _replace_protected_provider_account_login( + record: ProviderAccountRecord, + encrypted: EncryptedProviderAccountLogin, +) -> None: + record.login_ciphertext = encrypted.ciphertext + record.login_nonce = encrypted.nonce + record.login_wrapped_dek = encrypted.wrapped_dek + record.login_wrap_nonce = encrypted.wrap_nonce + record.login_kek_key_id = encrypted.kek_key_id + record.login_schema_version = encrypted.schema_version + + +def _safe_provider_account(record: ProviderAccountRecord) -> DiscoveredProviderAccount: + return DiscoveredProviderAccount( + id=record.id, + workspace_id=record.workspace_id, + connection_id=record.connection_id, + display_name=record.display_name, + account_type=cast(AccountType, record.account_type), + status=cast(AccountStatus, record.status), + capabilities=tuple(record.capabilities), + country_id=record.country_id, + currency=record.currency, + last_discovered_at=record.last_discovered_at, + last_verified_at=record.last_verified_at, + ) + + def _refresh_result( record: YandexProviderConnectionRecord, *, diff --git a/tests/integration/test_yandex_account_reconciliation.py b/tests/integration/test_yandex_account_reconciliation.py new file mode 100644 index 0000000..f15d5f0 --- /dev/null +++ b/tests/integration/test_yandex_account_reconciliation.py @@ -0,0 +1,564 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import NoReturn +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import ProviderAccountRecord, YandexProviderConnectionRecord +from app.db.rls import tenant_transaction +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.account_provider import ( + DirectAccountDiscoveryPage, + HttpxYandexDirectAccountProvider, +) +from app.modules.integrations.yandex.accounts import ( + AccountDiscoveryFailure, + AccountDiscoveryService, + ValidatedProviderAccount, +) +from app.modules.integrations.yandex.credentials import ( + CredentialKeyRing, + CredentialVault, + YandexCredentialPayload, +) +from app.modules.integrations.yandex.provider import YandexOAuthProviderFailure +from app.modules.integrations.yandex.refresh import ( + YandexConnectionLifecycleService, + YandexConnectionReauthorizationRequired, +) +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresYandexProviderAccountRepository, + PostgresYandexProviderConnectionRepository, + ProviderAccountDiscoveryUnavailable, +) + +_NOW = datetime(2026, 9, 6, 15, tzinfo=timezone.utc) + + +class _Provider: + def __init__(self, holder_page: DirectAccountDiscoveryPage) -> None: + self._holder_page = holder_page + self.calls = 0 + + def clients_get(self) -> DirectAccountDiscoveryPage: + self.calls += 1 + return self._holder_page + + def agencyclients_get(self, *, offset: int) -> DirectAccountDiscoveryPage: + raise AssertionError(f"unexpected agency page request: {offset}") + + +class _NoRefreshProvider: + def __init__(self) -> None: + self.calls = 0 + + def refresh_tokens(self, *, refresh_token: str) -> NoReturn: + del refresh_token + self.calls += 1 + raise AssertionError("disconnect must not refresh a provider connection") + + +class _InvalidGrantRefreshProvider: + def __init__(self) -> None: + self.calls = 0 + + def refresh_tokens(self, *, refresh_token: str) -> NoReturn: + del refresh_token + self.calls += 1 + raise YandexOAuthProviderFailure("invalid_grant") + + +def _row(client_id: int, login: str) -> dict[str, object]: + return { + "ClientId": client_id, + "Login": login, + "ClientInfo": f"Synthetic {client_id}", + "Type": "CLIENT", + "Archived": "NO", + "CountryId": 225, + "Currency": "RUB", + "Grants": [{"Privilege": "EDIT_CAMPAIGNS", "Value": "YES"}], + "Representatives": [{"Role": "CHIEF"}], + } + + +def _ordinary_provider(client_id: int, login: str) -> _Provider: + return _Provider(DirectAccountDiscoveryPage(client_rows=(_row(client_id, login),))) + + +@pytest.mark.integration +def test_reconciliation_preserves_failed_inventory_and_enforces_rls_uniqueness_and_version( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identities = PostgresIdentityRepository(app_runtime.sessions) + owner = identities.create_personal_workspace( + display_name="Synthetic reconciliation owner", + workspace_name="Synthetic reconciliation workspace", + ) + identity_repository = PostgresExternalIdentityRepository(app_runtime.sessions) + identity_repository.bind_yandex_identity( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + issuer="https://login.yandex.ru", + subject="synthetic-reconciliation-subject", + profile_login="synthetic-reconciliation-profile", + profile_display_name="Synthetic reconciliation profile", + now=_NOW, + ) + vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + connection = PostgresYandexProviderConnectionRepository( + app_runtime.sessions, + vault=vault, + ).persist_yandex_oauth_tokens( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + issuer="https://login.yandex.ru", + subject="synthetic-reconciliation-subject", + payload=YandexCredentialPayload( + access_token="synthetic-access-token", + refresh_token="synthetic-refresh-token", + access_token_expires_at=_NOW, + refresh_token_expires_at=None, + ), + now=_NOW, + ) + repository = PostgresYandexProviderAccountRepository(app_runtime.sessions, vault=vault) + service = AccountDiscoveryService(repository=repository) + + first = service.discover( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + provider=_ordinary_provider(701, "synthetic-routing-login-one"), + now=_NOW, + ) + second = service.discover( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + provider=_ordinary_provider(702, "synthetic-routing-login-two"), + now=_NOW, + ) + + assert len(first) == 1 + assert len(second) == 1 + assert first[0].id != second[0].id + with tenant_transaction( + app_runtime.sessions, + workspace_id=owner.workspace.id, + user_id=owner.user.id, + ) as session: + records_before_failure = { + record.provider_account_key: record.status + for record in session.scalars( + select(ProviderAccountRecord).order_by(ProviderAccountRecord.provider_account_key) + ) + } + assert records_before_failure == {"701": "stale", "702": "active"} + + malformed_provider = _Provider( + DirectAccountDiscoveryPage( + client_rows=( + _row(703, "synthetic-malformed-one"), + _row(704, "synthetic-malformed-two"), + ) + ) + ) + with pytest.raises(AccountDiscoveryFailure): + service.discover( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + provider=malformed_provider, + now=_NOW, + ) + assert malformed_provider.calls == 1 + with tenant_transaction( + app_runtime.sessions, + workspace_id=owner.workspace.id, + user_id=owner.user.id, + ) as session: + assert { + record.provider_account_key: record.status + for record in session.scalars( + select(ProviderAccountRecord).order_by(ProviderAccountRecord.provider_account_key) + ) + } == records_before_failure + + call_paths: list[str] = [] + + def null_limited_by_handler(request: httpx.Request) -> httpx.Response: + call_paths.append(request.url.path) + if request.url.path.endswith("/clients"): + return httpx.Response( + 200, + json={ + "result": { + "Clients": [ + { + "ClientId": 799, + "Login": "synthetic-null-limited-by-agency-login", + "ClientInfo": "Synthetic null limited by agency", + "Type": "AGENCY", + "Archived": "NO", + } + ] + } + }, + ) + agency_row = _row(703, "synthetic-null-limited-by-client-login") + agency_row["Type"] = "SUBCLIENT" + return httpx.Response( + 200, + json={"result": {"Clients": [agency_row], "LimitedBy": None}}, + ) + + null_limited_by_provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-null-limited-by-access-token", + transport=httpx.MockTransport(null_limited_by_handler), + sleeper=lambda _: None, + ) + with pytest.raises(AccountDiscoveryFailure) as null_limited_by_failure: + service.discover( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + provider=null_limited_by_provider, + now=_NOW, + ) + assert null_limited_by_failure.value.kind == "provider_malformed_response" + assert call_paths == ["/json/v5/clients", "/json/v5/agencyclients"] + with tenant_transaction( + app_runtime.sessions, + workspace_id=owner.workspace.id, + user_id=owner.user.id, + ) as session: + assert { + record.provider_account_key: record.status + for record in session.scalars( + select(ProviderAccountRecord).order_by(ProviderAccountRecord.provider_account_key) + ) + } == records_before_failure + + non_rfc_call_paths: list[str] = [] + raw_marker = "synthetic-non-rfc-integration-login" + non_rfc_body = ( + '{"result":{"Clients":[{"ClientId":705,"Login":"' + + raw_marker + + '","ClientInfo":"Synthetic non-RFC integration account","Type":"CLIENT",' + '"Archived":"NO","Grants":NaN}]}}' + ).encode("utf-8") + + def non_rfc_handler(request: httpx.Request) -> httpx.Response: + non_rfc_call_paths.append(request.url.path) + return httpx.Response(200, content=non_rfc_body) + + non_rfc_provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-non-rfc-integration-access-token", + transport=httpx.MockTransport(non_rfc_handler), + sleeper=lambda _: None, + ) + with pytest.raises(AccountDiscoveryFailure) as non_rfc_failure: + service.discover( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + provider=non_rfc_provider, + now=_NOW, + ) + assert non_rfc_failure.value.kind == "provider_malformed_response" + assert non_rfc_call_paths == ["/json/v5/clients"] + + def non_rfc_failure_is_redacted() -> bool: + return ( + raw_marker not in str(non_rfc_failure.value) + and raw_marker not in repr(non_rfc_failure.value) + ) + + assert non_rfc_failure_is_redacted() is True + with tenant_transaction( + app_runtime.sessions, + workspace_id=owner.workspace.id, + user_id=owner.user.id, + ) as session: + assert { + record.provider_account_key: record.status + for record in session.scalars( + select(ProviderAccountRecord).order_by(ProviderAccountRecord.provider_account_key) + ) + } == records_before_failure + + first_version = repository.begin_discovery( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + ) + second_version = repository.begin_discovery( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + ) + assert first_version == second_version + concurrent_candidate = ValidatedProviderAccount( + provider_account_key="703", + routing_login="synthetic-concurrent-routing-login", + display_name="Synthetic concurrent account", + account_type="advertiser", + status="active", + country_id=225, + currency="RUB", + ) + repository.reconcile_discovery( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + expected_connection_version=first_version, + candidates=(concurrent_candidate,), + now=_NOW, + ) + with pytest.raises(ProviderAccountDiscoveryUnavailable): + repository.reconcile_discovery( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + expected_connection_version=second_version, + candidates=(concurrent_candidate,), + now=_NOW, + ) + + with pytest.raises(IntegrityError): + with tenant_transaction( + app_runtime.sessions, + workspace_id=owner.workspace.id, + user_id=owner.user.id, + ) as session: + original = session.scalar( + select(ProviderAccountRecord).where( + ProviderAccountRecord.provider_account_key == "703" + ) + ) + assert original is not None + session.add( + ProviderAccountRecord( + id=uuid4(), + workspace_id=original.workspace_id, + connection_id=original.connection_id, + provider_account_key=original.provider_account_key, + account_type=original.account_type, + display_name=original.display_name, + status=original.status, + capabilities=original.capabilities, + country_id=original.country_id, + currency=original.currency, + login_ciphertext=original.login_ciphertext, + login_nonce=original.login_nonce, + login_wrapped_dek=original.login_wrapped_dek, + login_wrap_nonce=original.login_wrap_nonce, + login_kek_key_id=original.login_kek_key_id, + login_schema_version=original.login_schema_version, + last_discovered_at=original.last_discovered_at, + last_verified_at=original.last_verified_at, + created_at=original.created_at, + updated_at=original.updated_at, + version=1, + ) + ) + session.flush() + + other = identities.create_personal_workspace( + display_name="Synthetic isolated user", + workspace_name="Synthetic isolated workspace", + ) + blocked_provider = _ordinary_provider(704, "synthetic-cross-workspace-login") + with pytest.raises(ProviderAccountDiscoveryUnavailable): + service.discover( + user_id=other.user.id, + workspace_id=other.workspace.id, + connection_id=connection.id, + provider=blocked_provider, + now=_NOW, + ) + assert blocked_provider.calls == 0 + with tenant_transaction( + app_runtime.sessions, + workspace_id=other.workspace.id, + user_id=other.user.id, + ) as session: + assert session.scalar(select(ProviderAccountRecord.id)) is None + + with tenant_transaction( + app_runtime.sessions, + workspace_id=owner.workspace.id, + user_id=owner.user.id, + ) as session: + active_connection = session.scalar( + select(YandexProviderConnectionRecord) + .where(YandexProviderConnectionRecord.id == connection.id) + .with_for_update() + ) + assert active_connection is not None + active_connection.status = "reauth_required" + session.flush() + disabled_provider = _ordinary_provider(705, "synthetic-disabled-connection-login") + with pytest.raises(ProviderAccountDiscoveryUnavailable): + service.discover( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + provider=disabled_provider, + now=_NOW, + ) + assert disabled_provider.calls == 0 + with tenant_transaction( + app_runtime.sessions, + workspace_id=owner.workspace.id, + user_id=owner.user.id, + ) as session: + assert session.scalar(select(ProviderAccountRecord.id)) is None + finally: + owner_runtime.close() + app_runtime.close() + + +@pytest.mark.integration +@pytest.mark.parametrize("lifecycle_operation", ["disconnect", "invalid_grant"]) +def test_discovered_accounts_cascade_when_connection_lifecycle_deletes_parent( + postgres_service: object, + lifecycle_operation: str, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + owner = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic lifecycle owner", + workspace_name="Synthetic lifecycle workspace", + ) + subject = "synthetic-lifecycle-subject" + PostgresExternalIdentityRepository(app_runtime.sessions).bind_yandex_identity( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + profile_login="synthetic-lifecycle-profile", + profile_display_name="Synthetic lifecycle profile", + now=_NOW, + ) + vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + connections = PostgresYandexProviderConnectionRepository( + app_runtime.sessions, + vault=vault, + ) + connection = connections.persist_yandex_oauth_tokens( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + payload=YandexCredentialPayload( + access_token="synthetic-lifecycle-access-token", + refresh_token="synthetic-lifecycle-refresh-token", + access_token_expires_at=_NOW, + refresh_token_expires_at=None, + ), + now=_NOW, + ) + discovered = AccountDiscoveryService( + repository=PostgresYandexProviderAccountRepository(app_runtime.sessions, vault=vault) + ).discover( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + provider=_ordinary_provider(811, "synthetic-lifecycle-routing-login"), + now=_NOW, + ) + assert len(discovered) == 1 + + if lifecycle_operation == "disconnect": + provider = _NoRefreshProvider() + result = YandexConnectionLifecycleService( + repository=connections, + provider=provider, + ).disconnect( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + now=_NOW, + ) + assert result.local_credentials_purged is True + assert provider.calls == 0 + else: + provider = _InvalidGrantRefreshProvider() + with pytest.raises(YandexConnectionReauthorizationRequired): + YandexConnectionLifecycleService( + repository=connections, + provider=provider, + refresh_skew_seconds=0, + ).refresh( + user_id=owner.user.id, + workspace_id=owner.workspace.id, + connection_id=connection.id, + now=_NOW, + ) + assert provider.calls == 1 + + with owner_runtime.sessions() as session: + assert session.scalar( + select(YandexProviderConnectionRecord.id).where( + YandexProviderConnectionRecord.id == connection.id + ) + ) is None + assert session.scalar( + select(ProviderAccountRecord.id).where( + ProviderAccountRecord.connection_id == connection.id + ) + ) is None + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_yandex_accounts.py b/tests/integration/test_yandex_accounts.py new file mode 100644 index 0000000..6f74e2a --- /dev/null +++ b/tests/integration/test_yandex_accounts.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import ProviderAccountRecord +from app.db.rls import tenant_transaction +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.account_provider import DirectAccountDiscoveryPage +from app.modules.integrations.yandex.accounts import AccountDiscoveryService +from app.modules.integrations.yandex.credentials import ( + CredentialKeyRing, + CredentialVault, + CredentialVaultError, + EncryptedProviderAccountLogin, + ProviderAccountLoginContext, + YandexCredentialPayload, +) +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresYandexProviderAccountRepository, + PostgresYandexProviderConnectionRepository, +) + + +class _OrdinaryProvider: + def __init__(self, *, client_info: object = "Synthetic Provider Account") -> None: + self._client_info = client_info + + def clients_get(self) -> DirectAccountDiscoveryPage: + return DirectAccountDiscoveryPage( + client_rows=( + { + "ClientId": 123456789, + "Login": "synthetic-provider-routing-login", + "ClientInfo": self._client_info, + "Type": "CLIENT", + "Archived": "NO", + "CountryId": 225, + "Currency": "RUB", + "Grants": [{"Privilege": "EDIT_CAMPAIGNS", "Value": "YES"}], + "Representatives": [{"Role": "CHIEF"}], + }, + ) + ) + + def agencyclients_get(self, *, offset: int) -> DirectAccountDiscoveryPage: + raise AssertionError(f"unexpected agency discovery: {offset}") + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("client_info", "expected_display_name"), + [ + ("Synthetic Provider Account", "Synthetic Provider Account"), + (None, "Yandex Direct account 123456789"), + (" \t", "Yandex Direct account 123456789"), + ], +) +def test_discovery_persists_a_protected_login_and_returns_only_safe_account_data( + postgres_service: object, + client_info: object, + expected_display_name: str, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime(2026, 9, 6, 13, tzinfo=timezone.utc) + login = "synthetic-provider-routing-login" + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic account owner", + workspace_name="Synthetic account workspace", + ) + PostgresExternalIdentityRepository(app_runtime.sessions).bind_yandex_identity( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject="synthetic-account-subject", + profile_login="synthetic-profile-login", + profile_display_name="Synthetic profile name", + now=now, + ) + vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + connection = PostgresYandexProviderConnectionRepository( + app_runtime.sessions, + vault=vault, + ).persist_yandex_oauth_tokens( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject="synthetic-account-subject", + payload=YandexCredentialPayload( + access_token="synthetic-access-token", + refresh_token="synthetic-refresh-token", + access_token_expires_at=now, + refresh_token_expires_at=None, + ), + now=now, + ) + + result = AccountDiscoveryService( + repository=PostgresYandexProviderAccountRepository(app_runtime.sessions, vault=vault) + ).discover( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + connection_id=connection.id, + provider=_OrdinaryProvider(client_info=client_info), + now=now, + ) + + assert len(result) == 1 + safe_account = result[0] + assert safe_account.workspace_id == identity.workspace.id + assert safe_account.connection_id == connection.id + assert safe_account.account_type == "advertiser" + assert safe_account.status == "active" + assert safe_account.capabilities == ("direct.read",) + assert "direct.edit" not in safe_account.capabilities + assert _repr_excludes_login(value=safe_account, raw_login=login) is True + with tenant_transaction( + app_runtime.sessions, + workspace_id=identity.workspace.id, + user_id=identity.user.id, + ) as session: + record = session.scalar(select(ProviderAccountRecord)) + assert record is not None + assert record.provider_account_key == "123456789" + assert record.login_ciphertext != login.encode() + assert record.capabilities == ["direct.read"] + encrypted = EncryptedProviderAccountLogin( + ciphertext=record.login_ciphertext, + nonce=record.login_nonce, + wrapped_dek=record.login_wrapped_dek, + wrap_nonce=record.login_wrap_nonce, + kek_key_id=record.login_kek_key_id, + schema_version=record.login_schema_version, + ) + context = ProviderAccountLoginContext.for_yandex_provider_account( + workspace_id=identity.workspace.id, + connection_id=connection.id, + provider_account_id=record.id, + ) + assert _account_output_is_safe( + safe_account=safe_account, + record=record, + expected_display_name=expected_display_name, + raw_login=login, + ) is True + assert _provider_login_decrypts_only_with_correct_context( + vault=vault, + context=context, + encrypted=encrypted, + raw_login=login, + ) + for mismatched_context in ( + replace(context, workspace_id=uuid4()), + replace(context, connection_id=uuid4()), + replace(context, provider_account_id=uuid4()), + ): + with pytest.raises(CredentialVaultError): + vault.decrypt_provider_account_login( + context=mismatched_context, + encrypted=encrypted, + ) + finally: + owner_runtime.close() + app_runtime.close() + + +def _account_output_is_safe( + *, + safe_account: object, + record: ProviderAccountRecord, + expected_display_name: str, + raw_login: str, +) -> bool: + persisted_fields = {column.name: getattr(record, column.name) for column in record.__table__.columns} + return ( + getattr(safe_account, "display_name", None) == expected_display_name + and record.display_name == expected_display_name + and all(raw_login not in repr(value) for value in (safe_account, record, persisted_fields)) + and raw_login not in record.display_name + ) + + +def _repr_excludes_login(*, value: object, raw_login: str) -> bool: + return raw_login not in repr(value) + + +def _provider_login_decrypts_only_with_correct_context( + *, + vault: CredentialVault, + context: ProviderAccountLoginContext, + encrypted: EncryptedProviderAccountLogin, + raw_login: str, +) -> bool: + return vault.decrypt_provider_account_login(context=context, encrypted=encrypted) == raw_login diff --git a/tests/test_yandex_account_classification.py b/tests/test_yandex_account_classification.py new file mode 100644 index 0000000..3a4c871 --- /dev/null +++ b/tests/test_yandex_account_classification.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +import pytest + +from app.modules.integrations.yandex.account_provider import DirectAccountDiscoveryPage +from app.modules.integrations.yandex.accounts import ( + AccountDiscoveryFailure, + AccountDiscoveryService, +) + +_WORKSPACE_ID = UUID("00000000-0000-4000-8000-000000000021") +_CONNECTION_ID = UUID("00000000-0000-4000-8000-000000000022") +_USER_ID = UUID("00000000-0000-4000-8000-000000000023") +_NOW = datetime(2026, 9, 6, 14, tzinfo=timezone.utc) + + +class _Repository: + def __init__(self) -> None: + self.reconciled: list[tuple[tuple[Any, ...], int]] = [] + + def begin_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + ) -> int: + assert (user_id, workspace_id, connection_id) == ( + _USER_ID, + _WORKSPACE_ID, + _CONNECTION_ID, + ) + return 11 + + def reconcile_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + expected_connection_version: int, + candidates: tuple[Any, ...], + now: datetime, + ) -> tuple[Any, ...]: + assert (user_id, workspace_id, connection_id, now) == ( + _USER_ID, + _WORKSPACE_ID, + _CONNECTION_ID, + _NOW, + ) + self.reconciled.append((candidates, expected_connection_version)) + return () + + +class _ScriptedProvider: + def __init__( + self, + *, + holder: dict[str, object], + agency_pages: tuple[DirectAccountDiscoveryPage, ...] = (), + ) -> None: + self._holder = holder + self._agency_pages = list(agency_pages) + self.offsets: list[int] = [] + + def clients_get(self) -> DirectAccountDiscoveryPage: + return DirectAccountDiscoveryPage(client_rows=(self._holder,)) + + def agencyclients_get(self, *, offset: int) -> DirectAccountDiscoveryPage: + self.offsets.append(offset) + if not self._agency_pages: + raise AssertionError("unexpected agency page request") + return self._agency_pages.pop(0) + + +def _row( + *, + client_id: int, + login: str, + account_type: str, + archived: str = "NO", +) -> dict[str, object]: + return { + "ClientId": client_id, + "Login": login, + "ClientInfo": f"Synthetic {client_id}", + "Type": account_type, + "Archived": archived, + "CountryId": 225, + "Currency": "RUB", + "Grants": [{"Privilege": "EDIT_CAMPAIGNS", "Value": "YES"}], + "Representatives": [{"Role": "CHIEF"}], + } + + +def _discover( + *, + repository: _Repository, + provider: _ScriptedProvider, +) -> tuple[Any, ...]: + return AccountDiscoveryService(repository=repository).discover( + user_id=_USER_ID, + workspace_id=_WORKSPACE_ID, + connection_id=_CONNECTION_ID, + provider=provider, + now=_NOW, + ) + + +def test_subclient_holder_becomes_one_ordinary_agency_client_account() -> None: + repository = _Repository() + + result = _discover( + repository=repository, + provider=_ScriptedProvider( + holder=_row( + client_id=101, + login="synthetic-subclient-holder", + account_type="SUBCLIENT", + ) + ), + ) + + assert result == () + candidates, version = repository.reconciled[0] + assert version == 11 + assert len(candidates) == 1 + assert candidates[0].account_type == "agency_client" + assert candidates[0].provider_account_key == "101" + assert candidates[0].capabilities == ("direct.read",) + + +def test_agency_uses_limited_by_for_next_offset_and_persists_no_container() -> None: + repository = _Repository() + provider = _ScriptedProvider( + holder=_row( + client_id=201, + login="synthetic-agency-holder", + account_type="AGENCY", + ), + agency_pages=( + DirectAccountDiscoveryPage( + client_rows=( + _row( + client_id=301, + login="synthetic-agency-client-one", + account_type="SUBCLIENT", + ), + ), + limited_by=10_000, + ), + DirectAccountDiscoveryPage( + client_rows=( + _row( + client_id=302, + login="synthetic-agency-client-two", + account_type="SUBCLIENT", + ), + ) + ), + ), + ) + + _discover(repository=repository, provider=provider) + + assert provider.offsets == [0, 10_000] + candidates, _ = repository.reconciled[0] + assert [candidate.provider_account_key for candidate in candidates] == ["301", "302"] + assert {candidate.account_type for candidate in candidates} == {"agency_client"} + assert "201" not in {candidate.provider_account_key for candidate in candidates} + assert all(candidate.capabilities == ("direct.read",) for candidate in candidates) + + +def test_empty_agency_client_list_is_a_complete_successful_snapshot() -> None: + repository = _Repository() + provider = _ScriptedProvider( + holder=_row( + client_id=401, + login="synthetic-empty-agency-holder", + account_type="AGENCY", + ), + agency_pages=(DirectAccountDiscoveryPage(client_rows=()),), + ) + + assert _discover(repository=repository, provider=provider) == () + assert provider.offsets == [0] + assert repository.reconciled == [((), 11)] + + +@pytest.mark.parametrize( + "pages", + [ + ( + DirectAccountDiscoveryPage( + client_rows=( + _row( + client_id=501, + login="synthetic-invalid-type", + account_type="CLIENT", + ), + ) + ), + ), + ( + DirectAccountDiscoveryPage( + client_rows=( + _row( + client_id=502, + login="synthetic-duplicate-key", + account_type="SUBCLIENT", + ), + _row( + client_id=502, + login="synthetic-duplicate-key", + account_type="SUBCLIENT", + ), + ) + ), + ), + ( + DirectAccountDiscoveryPage( + client_rows=( + _row( + client_id=503, + login="synthetic-conflicting-login", + account_type="SUBCLIENT", + ), + _row( + client_id=504, + login="synthetic-conflicting-login", + account_type="SUBCLIENT", + ), + ) + ), + ), + (DirectAccountDiscoveryPage(client_rows=(), limited_by=10_000),), + ( + DirectAccountDiscoveryPage( + client_rows=( + _row( + client_id=505, + login="synthetic-invalid-pagination", + account_type="SUBCLIENT", + ), + ), + limited_by=0, + ), + ), + ], +) +def test_invalid_agency_rows_or_pagination_fail_closed_without_reconciliation( + pages: tuple[DirectAccountDiscoveryPage, ...], +) -> None: + repository = _Repository() + provider = _ScriptedProvider( + holder=_row( + client_id=500, + login="synthetic-invalid-agency-holder", + account_type="AGENCY", + ), + agency_pages=pages, + ) + + with pytest.raises(AccountDiscoveryFailure) as raised: + _discover(repository=repository, provider=provider) + + assert raised.value.kind == "provider_malformed_response" + assert repository.reconciled == [] diff --git a/tests/test_yandex_account_credentials.py b/tests/test_yandex_account_credentials.py new file mode 100644 index 0000000..36ec9df --- /dev/null +++ b/tests/test_yandex_account_credentials.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import replace +from uuid import uuid4 + +import pytest + +from app.modules.integrations.yandex.credentials import ( + CredentialKeyRing, + CredentialVault, + CredentialVaultError, + ProviderAccountLoginContext, +) + + +def test_vault_protects_provider_login_with_workspace_connection_and_account_context() -> None: + login = "synthetic-provider-routing-login" + vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + context = ProviderAccountLoginContext.for_yandex_provider_account( + workspace_id=uuid4(), + connection_id=uuid4(), + provider_account_id=uuid4(), + ) + + encrypted = vault.encrypt_provider_account_login(context=context, login=login) + + assert encrypted.ciphertext != login.encode() + assert login not in repr(encrypted) + assert login not in repr(context) + assert vault.decrypt_provider_account_login(context=context, encrypted=encrypted) == login + + +def test_vault_fails_closed_when_provider_login_account_context_changes() -> None: + login = "synthetic-provider-routing-login" + vault = CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + context = ProviderAccountLoginContext.for_yandex_provider_account( + workspace_id=uuid4(), + connection_id=uuid4(), + provider_account_id=uuid4(), + ) + encrypted = vault.encrypt_provider_account_login(context=context, login=login) + + with pytest.raises(CredentialVaultError) as raised: + vault.decrypt_provider_account_login( + context=replace(context, provider_account_id=uuid4()), + encrypted=encrypted, + ) + + assert login not in str(raised.value) + assert login not in repr(raised.value) diff --git a/tests/test_yandex_account_discovery.py b/tests/test_yandex_account_discovery.py new file mode 100644 index 0000000..676f91b --- /dev/null +++ b/tests/test_yandex_account_discovery.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json + +import httpx + +from app.modules.integrations.yandex.account_provider import ( + HttpxYandexDirectAccountProvider, +) + + +def test_clients_get_discovery_uses_exact_fixed_request_without_client_login() -> None: + captured: list[httpx.Request] = [] + holder_row = { + "ClientId": 123456789, + "Login": "synthetic-holder-login", + "ClientInfo": "Synthetic Holder", + "Type": "CLIENT", + "Archived": "NO", + "CountryId": 225, + "Currency": "RUB", + "Grants": [], + "Representatives": [], + } + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"result": {"Clients": [holder_row]}}) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=lambda _: None, + ) + + page = provider.clients_get() + + assert page.client_rows == (holder_row,) + assert page.limited_by is None + assert len(captured) == 1 + request = captured[0] + assert request.method == "POST" + assert str(request.url) == "https://api.direct.yandex.com/json/v5/clients" + assert request.headers["Authorization"] == "Bearer synthetic-discovery-access-token" + assert request.headers["Content-Type"] == "application/json; charset=utf-8" + assert request.headers["Accept-Language"] == "en" + assert "Client-Login" not in request.headers + assert json.loads(request.content) == { + "method": "get", + "params": { + "FieldNames": [ + "ClientId", + "Login", + "ClientInfo", + "Type", + "Archived", + "CountryId", + "Currency", + "Grants", + "Representatives", + ] + }, + } + + +def test_agencyclients_get_uses_fixed_page_and_never_client_login() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"result": {"Clients": [], "LimitedBy": 10_000}}) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=lambda _: None, + ) + + page = provider.agencyclients_get(offset=0) + + assert page.client_rows == () + assert page.limited_by == 10_000 + assert len(captured) == 1 + request = captured[0] + assert request.method == "POST" + assert str(request.url) == "https://api.direct.yandex.com/json/v5/agencyclients" + assert "Client-Login" not in request.headers + assert json.loads(request.content) == { + "method": "get", + "params": { + "SelectionCriteria": {}, + "FieldNames": [ + "ClientId", + "Login", + "ClientInfo", + "Archived", + "CountryId", + "Currency", + "Grants", + "Representatives", + "Type", + ], + "Page": {"Limit": 10_000, "Offset": 0}, + }, + } + + +def test_provider_retries_one_direct_temporary_failure_without_leaking_body() -> None: + attempts = 0 + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx.Response(200, json={"error": {"error_code": 52}}) + return httpx.Response(200, json={"result": {"Clients": []}}) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=sleeps.append, + ) + + assert provider.clients_get().client_rows == () + assert attempts == 2 + assert sleeps == [0.1] diff --git a/tests/test_yandex_account_provider_failures.py b/tests/test_yandex_account_provider_failures.py new file mode 100644 index 0000000..7cf9345 --- /dev/null +++ b/tests/test_yandex_account_provider_failures.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import inspect + +import httpx +import pytest + +from app.modules.integrations.yandex.account_provider import ( + DirectAccountDiscoveryProviderFailure, + HttpxYandexDirectAccountProvider, +) +from app.modules.integrations.yandex.accounts import AccountDiscoveryService + + +@pytest.mark.parametrize( + "first_response", + [ + lambda: httpx.Response(500, content=b"not-a-json-response"), + lambda: httpx.Response(503, content=b"not-a-json-response"), + lambda: httpx.Response(200, json={"error": {"error_code": 52}}), + lambda: httpx.Response(200, json={"error": {"error_code": 506}}), + lambda: httpx.Response(200, json={"error": {"error_code": 1000}}), + ], +) +def test_provider_retries_each_allowed_transient_result_once( + first_response: object, +) -> None: + attempts = 0 + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + if attempts == 1: + return first_response() # type: ignore[operator] + return httpx.Response(200, json={"result": {"Clients": []}}) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=sleeps.append, + ) + + assert provider.clients_get().client_rows == () + assert attempts == 2 + assert sleeps == [0.1] + + +def test_provider_retries_transport_failure_once() -> None: + attempts = 0 + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + if attempts == 1: + raise httpx.ConnectError("synthetic transport failure") + return httpx.Response(200, json={"result": {"Clients": []}}) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=sleeps.append, + ) + + assert provider.clients_get().client_rows == () + assert attempts == 2 + assert sleeps == [0.1] + + +@pytest.mark.parametrize( + "response", + [ + httpx.Response(200, content=b"not-json"), + httpx.Response(200, json={"error": {"error_code": 53}}), + httpx.Response(200, json={"error": {"error_code": 54}}), + httpx.Response(206, json={"result": {"Clients": []}}), + httpx.Response(401, json={"error": {"error_code": 53}}), + ], +) +def test_provider_never_retries_malformed_auth_permission_or_partial_content( + response: httpx.Response, +) -> None: + attempts = 0 + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + return response + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=sleeps.append, + ) + + with pytest.raises(DirectAccountDiscoveryProviderFailure) as raised: + provider.clients_get() + + assert attempts == 1 + assert sleeps == [] + assert "synthetic-discovery-access-token" not in repr(provider) + assert "synthetic-discovery-access-token" not in str(raised.value) + assert "not-json" not in repr(raised.value) + + +def test_provider_rejects_duplicate_json_keys_without_retry_or_raw_body_output() -> None: + attempts = 0 + raw_login = "synthetic-raw-provider-login" + body = ( + '{"result":{"Clients":[]},"result":{"Clients":[' + '{"Login":"' + raw_login + '"}]}}' + ).encode() + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + return httpx.Response(200, content=body) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=lambda _: None, + ) + + with pytest.raises(DirectAccountDiscoveryProviderFailure) as raised: + provider.clients_get() + + assert attempts == 1 + assert raised.value.kind == "provider_malformed_response" + assert raw_login not in str(raised.value) + assert raw_login not in repr(raised.value) + + +def test_provider_rejects_explicit_null_limited_by_without_retry() -> None: + attempts = 0 + sleeps: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + return httpx.Response(200, json={"result": {"Clients": [], "LimitedBy": None}}) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=sleeps.append, + ) + + with pytest.raises(DirectAccountDiscoveryProviderFailure) as raised: + provider.agencyclients_get(offset=0) + + assert raised.value.kind == "provider_malformed_response" + assert attempts == 1 + assert sleeps == [] + + +@pytest.mark.parametrize("non_rfc_constant", ["NaN", "Infinity", "-Infinity"]) +def test_provider_rejects_non_rfc_json_constants_without_retry_or_content_leak( + non_rfc_constant: str, +) -> None: + attempts = 0 + sleeps: list[float] = [] + raw_marker = "synthetic-non-rfc-provider-login" + body = ( + '{"result":{"Clients":[{"ClientId":123456789,"Login":"' + + raw_marker + + '","ClientInfo":"Synthetic non-RFC account","Type":"CLIENT",' + '"Archived":"NO","Grants":' + + non_rfc_constant + + "}]}}" + ).encode("utf-8") + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + return httpx.Response(200, content=body) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=sleeps.append, + ) + + with pytest.raises(DirectAccountDiscoveryProviderFailure) as raised: + provider.clients_get() + + assert raised.value.kind == "provider_malformed_response" + assert attempts == 1 + assert sleeps == [] + assert _failure_is_redacted(failure=raised.value, raw_marker=raw_marker) is True + + +def _failure_is_redacted( + *, + failure: DirectAccountDiscoveryProviderFailure, + raw_marker: str, +) -> bool: + return raw_marker not in str(failure) and raw_marker not in repr(failure) + + +def test_provider_uses_explicit_ten_second_timeout() -> None: + observed_timeout: dict[str, float] = {} + + def handler(request: httpx.Request) -> httpx.Response: + observed_timeout.update(request.extensions["timeout"]) + return httpx.Response(200, json={"result": {"Clients": []}}) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=lambda _: None, + ) + + provider.clients_get() + + assert observed_timeout == { + "connect": 10.0, + "read": 10.0, + "write": 10.0, + "pool": 10.0, + } + + +def test_discovery_boundaries_do_not_accept_caller_supplied_login_or_client_login() -> None: + provider_parameters = inspect.signature(HttpxYandexDirectAccountProvider).parameters + service_parameters = inspect.signature(AccountDiscoveryService.discover).parameters + + assert {"login", "client_login", "clientlogin"}.isdisjoint(provider_parameters) + assert {"login", "client_login", "clientlogin"}.isdisjoint(service_parameters) + assert not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in (*provider_parameters.values(), *service_parameters.values()) + ) diff --git a/tests/test_yandex_accounts.py b/tests/test_yandex_accounts.py new file mode 100644 index 0000000..eb33477 --- /dev/null +++ b/tests/test_yandex_accounts.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +import httpx +import pytest + +from app.modules.integrations.yandex.account_provider import ( + DirectAccountDiscoveryPage, + HttpxYandexDirectAccountProvider, +) +from app.modules.integrations.yandex.accounts import ( + AccountDiscoveryFailure, + AccountDiscoveryService, + DiscoveredProviderAccount, + ValidatedProviderAccount, +) + +_WORKSPACE_ID = UUID("00000000-0000-4000-8000-000000000011") +_CONNECTION_ID = UUID("00000000-0000-4000-8000-000000000012") +_USER_ID = UUID("00000000-0000-4000-8000-000000000013") +_NOW = datetime(2026, 9, 6, 13, tzinfo=timezone.utc) + + +class _OrdinaryProvider: + def __init__( + self, + *, + client_info: object = "Synthetic Holder", + login: str = "synthetic-holder-login", + ) -> None: + self._client_info = client_info + self._login = login + + def clients_get(self) -> DirectAccountDiscoveryPage: + return DirectAccountDiscoveryPage( + client_rows=( + { + "ClientId": 123456789, + "Login": self._login, + "ClientInfo": self._client_info, + "Type": "CLIENT", + "Archived": "NO", + "CountryId": 225, + "Currency": "RUB", + "Grants": [{"Privilege": "EDIT_CAMPAIGNS", "Value": "YES"}], + "Representatives": [{"Role": "CHIEF"}], + }, + ) + ) + + def agencyclients_get(self, *, offset: int) -> DirectAccountDiscoveryPage: + raise AssertionError(f"ordinary discovery must not enumerate agency clients: {offset}") + + +class _RecordingRepository: + def __init__(self) -> None: + self.begin_calls: list[tuple[UUID, UUID, UUID]] = [] + self.reconcile_calls: list[tuple[object, ...]] = [] + + def begin_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + ) -> int: + self.begin_calls.append((user_id, workspace_id, connection_id)) + return 7 + + def reconcile_discovery( + self, + *, + user_id: UUID, + workspace_id: UUID, + connection_id: UUID, + expected_connection_version: int, + candidates: tuple[ValidatedProviderAccount, ...], + now: datetime, + ) -> tuple[DiscoveredProviderAccount, ...]: + self.reconcile_calls.append( + ( + user_id, + workspace_id, + connection_id, + expected_connection_version, + candidates, + now, + ) + ) + return () + + +def test_client_discovery_normalizes_one_read_only_account_without_login_in_repr() -> None: + repository = _RecordingRepository() + service = AccountDiscoveryService(repository=repository) + + result = service.discover( + user_id=_USER_ID, + workspace_id=_WORKSPACE_ID, + connection_id=_CONNECTION_ID, + provider=_OrdinaryProvider(), + now=_NOW, + ) + + assert result == () + assert repository.begin_calls == [(_USER_ID, _WORKSPACE_ID, _CONNECTION_ID)] + assert len(repository.reconcile_calls) == 1 + _, _, _, version, candidates, now = repository.reconcile_calls[0] + assert version == 7 + assert now == _NOW + assert len(candidates) == 1 + candidate = candidates[0] + assert candidate.provider_account_key == "123456789" + assert candidate.display_name == "Synthetic Holder" + assert candidate.account_type == "advertiser" + assert candidate.status == "active" + assert candidate.country_id == 225 + assert candidate.currency == "RUB" + assert candidate.capabilities == ("direct.read",) + assert "direct.edit" not in candidate.capabilities + assert "synthetic-holder-login" not in repr(candidate) + + +@pytest.mark.parametrize("client_info", [None, " \t"]) +def test_client_discovery_uses_non_sensitive_label_when_client_info_missing_or_blank( + client_info: object, +) -> None: + repository = _RecordingRepository() + raw_login = "synthetic-login-that-must-not-display" + + AccountDiscoveryService(repository=repository).discover( + user_id=_USER_ID, + workspace_id=_WORKSPACE_ID, + connection_id=_CONNECTION_ID, + provider=_OrdinaryProvider(client_info=client_info, login=raw_login), + now=_NOW, + ) + + _, _, _, _, candidates, _ = repository.reconcile_calls[0] + assert _candidate_has_safe_fallback(candidate=candidates[0], raw_login=raw_login) is True + + +def _candidate_has_safe_fallback(*, candidate: object, raw_login: str) -> bool: + return ( + getattr(candidate, "display_name", None) == "Yandex Direct account 123456789" + and raw_login not in repr(candidate) + ) + + +def test_non_rfc_provider_page_fails_before_repository_reconciliation() -> None: + repository = _RecordingRepository() + attempts = 0 + sleeps: list[float] = [] + raw_marker = "synthetic-non-rfc-service-login" + body = ( + '{"result":{"Clients":[{"ClientId":123456789,"Login":"' + + raw_marker + + '","ClientInfo":"Synthetic non-RFC service account","Type":"CLIENT",' + '"Archived":"NO","Representatives":Infinity}]}}' + ).encode("utf-8") + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal attempts + attempts += 1 + return httpx.Response(200, content=body) + + provider = HttpxYandexDirectAccountProvider( + access_token="synthetic-discovery-access-token", + transport=httpx.MockTransport(handler), + sleeper=sleeps.append, + ) + + with pytest.raises(AccountDiscoveryFailure) as raised: + AccountDiscoveryService(repository=repository).discover( + user_id=_USER_ID, + workspace_id=_WORKSPACE_ID, + connection_id=_CONNECTION_ID, + provider=provider, + now=_NOW, + ) + + assert raised.value.kind == "provider_malformed_response" + assert attempts == 1 + assert sleeps == [] + assert repository.reconcile_calls == [] + assert _service_failure_is_redacted(failure=raised.value, raw_marker=raw_marker) is True + + +def _service_failure_is_redacted(*, failure: AccountDiscoveryFailure, raw_marker: str) -> bool: + return raw_marker not in str(failure) and raw_marker not in repr(failure) + + +def test_agency_holder_with_missing_required_identity_fails_before_reconciliation() -> None: + repository = _RecordingRepository() + + class _MalformedAgencyProvider: + def clients_get(self) -> DirectAccountDiscoveryPage: + return DirectAccountDiscoveryPage( + client_rows=( + { + "Login": "synthetic-agency-login", + "Type": "AGENCY", + "Archived": "NO", + }, + ) + ) + + def agencyclients_get(self, *, offset: int) -> DirectAccountDiscoveryPage: + assert offset == 0 + return DirectAccountDiscoveryPage(client_rows=()) + + with pytest.raises(AccountDiscoveryFailure) as raised: + AccountDiscoveryService(repository=repository).discover( + user_id=_USER_ID, + workspace_id=_WORKSPACE_ID, + connection_id=_CONNECTION_ID, + provider=_MalformedAgencyProvider(), + now=_NOW, + ) + + assert raised.value.kind == "provider_malformed_response" + assert repository.reconcile_calls == [] From 573f593431157808c7caa437d48a23cf3b3986a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 10:42:57 +0300 Subject: [PATCH 5/8] test: update Yandex integration route inventory --- tests/characterization/test_route_inventory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/characterization/test_route_inventory.py b/tests/characterization/test_route_inventory.py index 04f8193..540b1aa 100644 --- a/tests/characterization/test_route_inventory.py +++ b/tests/characterization/test_route_inventory.py @@ -34,8 +34,8 @@ def test_openapi_baseline_route_inventory_matches_current_snapshot() -> None: for schema in (snapshot, generated): assert schema["info"]["title"] == "DirectPilot Beta API" assert schema["info"]["version"] == "0.2.1" - assert len(schema["paths"]) == 85 - assert _operation_count(schema) == 99 + assert len(schema["paths"]) == 89 + assert _operation_count(schema) == 103 assert schema.get("components", {}).get("securitySchemes", {}) == {} for route, method in _EXPECTED_ROUTES.items(): assert method in schema["paths"][route] From f6c1921603c8f752eccbe0fe0bb826caaa684405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 13:58:04 +0300 Subject: [PATCH 6/8] feat(yandex): add connection-scoped Direct client factory --- app/bootstrap/dependencies.py | 20 +- .../integrations/yandex/client_factory.py | 436 ++++++++++++ app/yandex_direct.py | 138 +++- .../test_application_persistence_runtime.py | 13 + ...yandex_connection_scoped_client_factory.py | 312 ++++++++ tests/test_dependency_seams.py | 112 +++ ...yandex_connection_scoped_client_factory.py | 671 ++++++++++++++++++ tests/test_yandex_direct_client.py | 145 ++++ 8 files changed, 1815 insertions(+), 32 deletions(-) create mode 100644 app/modules/integrations/yandex/client_factory.py create mode 100644 tests/integration/test_yandex_connection_scoped_client_factory.py create mode 100644 tests/test_yandex_connection_scoped_client_factory.py diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py index 2511e29..7164faf 100644 --- a/app/bootstrap/dependencies.py +++ b/app/bootstrap/dependencies.py @@ -15,6 +15,10 @@ ) from app.db.schema import check_schema_compatibility from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.client_factory import ( + ConnectionScopedDirectClientFactory, + PostgresConnectionScopedDirectClientFactory, +) from app.modules.integrations.yandex.credentials import ( CredentialConfigurationError, CredentialKeyRing, @@ -65,6 +69,7 @@ class ApplicationDependencies: direct_client_factory: DirectClientFactory metrika_client_factory: MetrikaClientFactory wordstat_client_factory: WordstatClientFactory + connection_scoped_direct_client_factory: ConnectionScopedDirectClientFactory | None = None database_runtime: DatabaseRuntime | None = None identity_repository: PostgresIdentityRepository | None = None session_service: PostgresSessionService | None = None @@ -110,10 +115,13 @@ def create_application_dependencies() -> ApplicationDependencies: credential_persister = None yandex_provider = None connection_lifecycle = None + credential_vault = None + if settings.credential_keyring_secret_file: + credential_vault = _configured_credential_vault(settings) if oauth_config is not None: connection_repository = PostgresYandexProviderConnectionRepository( runtime.sessions, - vault=_configured_credential_vault(settings), + vault=credential_vault or _configured_credential_vault(settings), ) credential_persister = connection_repository yandex_provider = HttpxYandexOAuthProvider( @@ -132,6 +140,16 @@ def create_application_dependencies() -> ApplicationDependencies: direct_client_factory=DefaultDirectClientFactory(), metrika_client_factory=DefaultMetrikaClientFactory(), wordstat_client_factory=DefaultWordstatClientFactory(), + connection_scoped_direct_client_factory=( + None + if credential_vault is None + else PostgresConnectionScopedDirectClientFactory( + sessions=runtime.sessions, + vault=credential_vault, + workspace_authorizer=PostgresWorkspaceAuthorizer(runtime.sessions), + settings=settings, + ) + ), database_runtime=runtime, identity_repository=PostgresIdentityRepository(runtime.sessions), session_service=PostgresSessionService(runtime.sessions), diff --git a/app/modules/integrations/yandex/client_factory.py b/app/modules/integrations/yandex/client_factory.py new file mode 100644 index 0000000..bdbe715 --- /dev/null +++ b/app/modules/integrations/yandex/client_factory.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Literal, Protocol +from uuid import UUID + +import httpx +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from app.config import Settings +from app.db.models import ProviderAccountRecord, YandexProviderConnectionRecord +from app.db.rls import WorkspaceContextError, tenant_transaction +from app.modules.integrations.yandex.credentials import ( + CredentialContext, + CredentialVault, + CredentialVaultError, + EncryptedProviderAccountLogin, + EncryptedYandexCredential, + ProviderAccountLoginContext, +) +from app.modules.integrations.yandex.refresh import ( + DEFAULT_REFRESH_SKEW_SECONDS, + is_refresh_due, +) +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.modules.tenancy.policy import AuthorizationDenied, Capability +from app.yandex_direct import YandexDirectClient + +_REQUEST_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +_ALLOWED_RUNTIME_MODES = frozenset({"sandbox", "live_readonly", "live_write"}) +_ALLOWED_ACCOUNT_TYPES = frozenset({"advertiser", "agency_client"}) +_DIRECT_READ_CAPABILITY = "direct.read" + + +class DirectClientFactoryError(RuntimeError): + """Base class for safe connection-scoped Direct client failures.""" + + +class DirectClientAuthorizationDenied(DirectClientFactoryError): + """The current principal cannot read this workspace.""" + + +class DirectClientBindingNotFound(DirectClientFactoryError): + """The requested connection/account binding is absent or not tenant-owned.""" + + +class DirectClientBindingUnavailable(DirectClientFactoryError): + """The requested binding is present but cannot safely serve Direct reads.""" + + +class DirectClientCredentialUnavailable(DirectClientFactoryError): + """The encrypted connection credential or routing login cannot be used.""" + + +class DirectClientRefreshRequired(DirectClientFactoryError): + """The access credential is due for P4-04 lifecycle refresh.""" + + +class DirectClientLeaseInvalidated(DirectClientFactoryError): + """A previously resolved tenant binding no longer matches its lease snapshot.""" + + +class DirectClientResolutionUnavailable(DirectClientFactoryError): + """The local tenant-bound resolution path is temporarily unavailable.""" + + +class DirectClientClosed(DirectClientResolutionUnavailable): + """The request-bounded Direct client lease has already been closed.""" + + +@dataclass(frozen=True, slots=True) +class DirectClientRequest: + user_id: UUID + workspace_id: UUID + provider_connection_id: UUID + provider_account_id: UUID + request_id: str + runtime_mode: Literal["sandbox", "live_readonly", "live_write"] + spend_agency_units: Literal[False] = False + + def __post_init__(self) -> None: + if not all( + isinstance(value, UUID) + for value in ( + self.user_id, + self.workspace_id, + self.provider_connection_id, + self.provider_account_id, + ) + ): + raise ValueError("Direct client request is invalid") + if not isinstance(self.request_id, str) or not _REQUEST_ID_PATTERN.fullmatch(self.request_id): + raise ValueError("Direct client request is invalid") + if self.runtime_mode not in _ALLOWED_RUNTIME_MODES or self.spend_agency_units is not False: + raise ValueError("Direct client request is invalid") + + +class ConnectionScopedDirectClientFactory(Protocol): + def create(self, *, request: DirectClientRequest) -> DirectClientLease: ... + + +class DirectClientLease: + """A closeable request lease that holds no public credential fields.""" + + def __init__( + self, + *, + client: YandexDirectClient, + connection_id: UUID, + provider_account_id: UUID, + request_id: str, + ) -> None: + self._client: YandexDirectClient | None = client + self.connection_id = connection_id + self.provider_account_id = provider_account_id + self.request_id = request_id + + @property + def client(self) -> YandexDirectClient: + client = self._client + if client is None: + raise DirectClientClosed("Yandex Direct client lease is closed") + return client + + def close(self) -> None: + client = self._client + if client is None: + return + self._client = None + client.close() + + def __enter__(self) -> YandexDirectClient: + return self.client + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + self.close() + + +@dataclass(frozen=True, slots=True) +class _BindingSnapshot: + user_id: UUID + workspace_id: UUID + connection_id: UUID + provider_account_id: UUID + connection_version: int + provider_account_version: int + + +class _DirectClientLeaseVerifier: + def __init__( + self, + *, + sessions: sessionmaker[Session], + workspace_authorizer: PostgresWorkspaceAuthorizer, + snapshot: _BindingSnapshot, + ) -> None: + self._sessions = sessions + self._workspace_authorizer = workspace_authorizer + self._snapshot = snapshot + + def __call__(self) -> None: + snapshot = self._snapshot + try: + self._workspace_authorizer.authorize( + user_id=snapshot.user_id, + workspace_id=snapshot.workspace_id, + capability=Capability.READ_WORKSPACE_DATA, + ) + except AuthorizationDenied: + raise DirectClientLeaseInvalidated("Yandex Direct client lease is invalidated") from None + except (SQLAlchemyError, WorkspaceContextError): + raise DirectClientResolutionUnavailable( + "Yandex Direct client resolution is unavailable" + ) from None + + try: + with tenant_transaction( + self._sessions, + workspace_id=snapshot.workspace_id, + user_id=snapshot.user_id, + ) as session: + connection = session.scalar( + select(YandexProviderConnectionRecord) + .where( + YandexProviderConnectionRecord.id == snapshot.connection_id, + YandexProviderConnectionRecord.workspace_id == snapshot.workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + ) + .with_for_update() + ) + account = session.scalar( + select(ProviderAccountRecord) + .where( + ProviderAccountRecord.id == snapshot.provider_account_id, + ProviderAccountRecord.workspace_id == snapshot.workspace_id, + ProviderAccountRecord.connection_id == snapshot.connection_id, + ) + .with_for_update() + ) + if connection is None or account is None: + raise DirectClientLeaseInvalidated("Yandex Direct client lease is invalidated") + if not _binding_is_available(connection=connection, account=account): + raise DirectClientLeaseInvalidated("Yandex Direct client lease is invalidated") + if ( + connection.version != snapshot.connection_version + or account.version != snapshot.provider_account_version + ): + raise DirectClientLeaseInvalidated("Yandex Direct client lease is invalidated") + except DirectClientLeaseInvalidated: + raise + except (SQLAlchemyError, WorkspaceContextError): + raise DirectClientResolutionUnavailable( + "Yandex Direct client resolution is unavailable" + ) from None + + +class PostgresConnectionScopedDirectClientFactory: + """Resolve one authorized active Yandex account into a read-only Direct lease.""" + + def __init__( + self, + *, + sessions: sessionmaker[Session], + vault: CredentialVault, + workspace_authorizer: PostgresWorkspaceAuthorizer, + settings: Settings, + transport: httpx.BaseTransport | None = None, + ) -> None: + self._sessions = sessions + self._vault = vault + self._workspace_authorizer = workspace_authorizer + self._settings = settings + self._transport = transport + + def create(self, *, request: DirectClientRequest) -> DirectClientLease: + self._authorize(request=request) + snapshot, access_token, client_login = self._resolve_binding(request=request) + verifier = _DirectClientLeaseVerifier( + sessions=self._sessions, + workspace_authorizer=self._workspace_authorizer, + snapshot=snapshot, + ) + try: + client = YandexDirectClient( + settings=self._settings, + access_token=access_token, + client_login=client_login, + use_operator_units=False, + request_context=verifier, + transport=self._transport, + ) + finally: + del access_token + del client_login + return DirectClientLease( + client=client, + connection_id=snapshot.connection_id, + provider_account_id=snapshot.provider_account_id, + request_id=request.request_id, + ) + + def _authorize(self, *, request: DirectClientRequest) -> None: + try: + self._workspace_authorizer.authorize( + user_id=request.user_id, + workspace_id=request.workspace_id, + capability=Capability.READ_WORKSPACE_DATA, + ) + except AuthorizationDenied: + raise DirectClientAuthorizationDenied("Yandex Direct client authorization is denied") from None + except (SQLAlchemyError, WorkspaceContextError): + raise DirectClientResolutionUnavailable( + "Yandex Direct client resolution is unavailable" + ) from None + + def _resolve_binding( + self, + *, + request: DirectClientRequest, + ) -> tuple[_BindingSnapshot, str, str | None]: + try: + with tenant_transaction( + self._sessions, + workspace_id=request.workspace_id, + user_id=request.user_id, + ) as session: + connection = session.scalar( + select(YandexProviderConnectionRecord) + .where( + YandexProviderConnectionRecord.id == request.provider_connection_id, + YandexProviderConnectionRecord.workspace_id == request.workspace_id, + YandexProviderConnectionRecord.provider == "yandex", + ) + .with_for_update() + ) + if connection is None: + raise DirectClientBindingNotFound("Yandex Direct client binding is not found") + account = session.scalar( + select(ProviderAccountRecord) + .where( + ProviderAccountRecord.id == request.provider_account_id, + ProviderAccountRecord.workspace_id == request.workspace_id, + ProviderAccountRecord.connection_id == request.provider_connection_id, + ) + .with_for_update() + ) + if account is None: + raise DirectClientBindingNotFound("Yandex Direct client binding is not found") + if ( + connection.id != request.provider_connection_id + or connection.workspace_id != request.workspace_id + or connection.provider != "yandex" + or account.id != request.provider_account_id + or account.workspace_id != request.workspace_id + or account.connection_id != connection.id + ): + raise DirectClientBindingNotFound("Yandex Direct client binding is not found") + if not _binding_is_available(connection=connection, account=account): + raise DirectClientBindingUnavailable( + "Yandex Direct client binding is unavailable" + ) + + try: + credential = self._vault.decrypt( + context=CredentialContext.for_yandex_connection( + workspace_id=request.workspace_id, + connection_id=connection.id, + ), + encrypted=_encrypted_credential(connection), + ) + try: + if is_refresh_due( + now=_utc_now(), + expires_at=credential.access_token_expires_at, + skew_seconds=DEFAULT_REFRESH_SKEW_SECONDS, + ): + raise DirectClientRefreshRequired( + "Yandex Direct client refresh is required" + ) + access_token = credential.access_token + finally: + del credential + + client_login: str | None = None + if account.account_type == "agency_client": + client_login = self._vault.decrypt_provider_account_login( + context=ProviderAccountLoginContext.for_yandex_provider_account( + workspace_id=request.workspace_id, + connection_id=connection.id, + provider_account_id=account.id, + ), + encrypted=_encrypted_provider_account_login(account), + ) + except DirectClientRefreshRequired: + raise + except (CredentialVaultError, ValueError): + raise DirectClientCredentialUnavailable( + "Yandex Direct client credential is unavailable" + ) from None + + return ( + _BindingSnapshot( + user_id=request.user_id, + workspace_id=request.workspace_id, + connection_id=connection.id, + provider_account_id=account.id, + connection_version=connection.version, + provider_account_version=account.version, + ), + access_token, + client_login, + ) + except ( + DirectClientBindingNotFound, + DirectClientBindingUnavailable, + DirectClientCredentialUnavailable, + DirectClientRefreshRequired, + ): + raise + except (SQLAlchemyError, WorkspaceContextError): + raise DirectClientResolutionUnavailable( + "Yandex Direct client resolution is unavailable" + ) from None + + +def _binding_is_available( + *, + connection: YandexProviderConnectionRecord | None, + account: ProviderAccountRecord | None, +) -> bool: + return ( + connection is not None + and account is not None + and connection.provider == "yandex" + and account.workspace_id == connection.workspace_id + and account.connection_id == connection.id + and connection.status == "active" + and account.status == "active" + and account.account_type in _ALLOWED_ACCOUNT_TYPES + and _DIRECT_READ_CAPABILITY in account.capabilities + ) + + +def _encrypted_credential(record: YandexProviderConnectionRecord) -> EncryptedYandexCredential: + return EncryptedYandexCredential( + token_ciphertext=record.token_ciphertext, + token_nonce=record.token_nonce, + wrapped_dek=record.wrapped_dek, + wrap_nonce=record.wrap_nonce, + kek_key_id=record.kek_key_id, + schema_version=record.schema_version, + access_token_expires_at=record.access_token_expires_at, + refresh_token_expires_at=record.refresh_token_expires_at, + ) + + +def _encrypted_provider_account_login( + record: ProviderAccountRecord, +) -> EncryptedProviderAccountLogin: + return EncryptedProviderAccountLogin( + ciphertext=record.login_ciphertext, + nonce=record.login_nonce, + wrapped_dek=record.login_wrapped_dek, + wrap_nonce=record.login_wrap_nonce, + kek_key_id=record.login_kek_key_id, + schema_version=record.login_schema_version, + ) + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) diff --git a/app/yandex_direct.py b/app/yandex_direct.py index 91e0167..3dfdbf0 100644 --- a/app/yandex_direct.py +++ b/app/yandex_direct.py @@ -2,7 +2,7 @@ import hashlib import json -from typing import Any +from typing import Any, Callable import httpx @@ -10,6 +10,7 @@ SANDBOX_BASE_URL = "https://api-sandbox.direct.yandex.com/json/v5" LIVE_BASE_URL = "https://api.direct.yandex.com/json/v5" +_UNSET = object() class YandexDirectError(RuntimeError): @@ -33,10 +34,96 @@ def __init__( class YandexDirectClient: - def __init__(self, settings: Settings, transport: httpx.BaseTransport | None = None) -> None: + def __init__( + self, + settings: Settings, + transport: httpx.BaseTransport | None = None, + *, + access_token: str | None | object = _UNSET, + client_login: str | None = None, + use_operator_units: bool = False, + request_context: Callable[[], None] | None = None, + ) -> None: + if access_token is _UNSET: + if client_login is not None or use_operator_units or request_context is not None: + raise ValueError("Explicit Direct options require an explicit credential") + self._connection_scoped = False + self._access_token: str | None = None + else: + if not isinstance(access_token, str) or not access_token: + raise ValueError("An explicit Yandex Direct credential is required") + if use_operator_units: + raise ValueError("Connection-scoped Direct clients cannot use agency units") + self._connection_scoped = True + self._access_token = access_token + self._client_login = client_login + self._request_context = request_context + self._closed = False self.settings = settings self._client = httpx.Client(timeout=20, transport=transport) + def close(self) -> None: + if self._closed: + return + self._closed = True + self._client.close() + self._access_token = None + self._client_login = None + self._request_context = None + + def _ensure_open(self) -> None: + if self._closed: + raise YandexDirectError("Yandex Direct client is closed") + + def _before_provider_request(self, *, method: str | None) -> None: + self._ensure_open() + if self._connection_scoped and method is not None and method != "get": + raise YandexDirectError("Connection-scoped Direct clients allow only get methods") + if self._request_context is not None: + self._request_context() + + def _request_headers(self, *, language: str, report: bool) -> dict[str, str]: + self._ensure_open() + access_token: str | None + if self._connection_scoped: + if self._access_token is None: + raise YandexDirectError("Connection-scoped Direct credential is unavailable") + access_token = self._access_token + else: + access_token = self.settings.yandex_oauth_token + if not access_token: + raise YandexDirectError("YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls") + + if access_token is None: + raise YandexDirectError("Yandex Direct credential is unavailable") + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept-Language": language, + } + if self._client_login is not None: + headers["Client-Login"] = self._client_login + if report: + headers.update( + { + "processingMode": "auto", + "returnMoneyInMicros": "false", + "skipReportHeader": "true", + "skipColumnHeader": "false", + "skipReportSummary": "true", + } + ) + return headers + + def _provider_error(self, error: dict[str, Any]) -> dict[str, Any]: + if self._connection_scoped: + return {"error_code": error.get("error_code")} + return { + "error_code": error.get("error_code"), + "error_detail": error.get("error_detail"), + "error_string": error.get("error_string"), + } + @property def base_url(self) -> str: if self.settings.directpilot_mode == "sandbox": @@ -919,6 +1006,11 @@ def account_balance(self, login: str | None = None) -> dict[str, Any]: ``Currency`` and ``AccountDayBudget`` — the four fields the user needs to decide whether the account can keep serving impressions. """ + self._ensure_open() + if self._connection_scoped: + raise YandexDirectError( + "Connection-scoped Direct clients do not support account balance" + ) if not self.settings.yandex_oauth_token: raise YandexDirectError("YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls") @@ -1508,22 +1600,13 @@ def _control_campaign(self, campaign_id: int | str, method: str) -> dict[str, An return self._call("campaigns", payload) def _call_report(self, payload: dict[str, Any]) -> dict[str, Any]: - if not self.settings.yandex_oauth_token: - raise YandexDirectError("YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls") + self._before_provider_request(method=None) try: response = self._client.post( f"{self.base_url}/reports", json=payload, - headers={ - "Authorization": f"Bearer {self.settings.yandex_oauth_token}", - "Accept-Language": "en", - "processingMode": "auto", - "returnMoneyInMicros": "false", - "skipReportHeader": "true", - "skipColumnHeader": "false", - "skipReportSummary": "true", - }, + headers=self._request_headers(language="en", report=True), ) except httpx.HTTPError as exc: raise YandexDirectError( @@ -1545,11 +1628,7 @@ def _call_report(self, payload: dict[str, Any]) -> dict[str, Any]: if isinstance(error, dict): return { "ok": False, - "error": { - "error_code": error.get("error_code"), - "error_detail": error.get("error_detail"), - "error_string": error.get("error_string"), - }, + "error": self._provider_error(error), "units": response.headers.get("Units"), } return { @@ -1561,22 +1640,18 @@ def _call_report(self, payload: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "result": response.text, "units": response.headers.get("Units")} def _call(self, service: str, payload: dict[str, Any]) -> dict[str, Any]: - diagnostics = { + diagnostics: dict[str, Any] = { "provider": "yandex_direct", "service": service, "method": str(payload.get("method") or ""), } - if not self.settings.yandex_oauth_token: - raise YandexDirectError("YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls") + self._before_provider_request(method=diagnostics["method"]) try: response = self._client.post( f"{self.base_url}/{service}", json=payload, - headers={ - "Authorization": f"Bearer {self.settings.yandex_oauth_token}", - "Accept-Language": "ru", - }, + headers=self._request_headers(language="ru", report=False), ) except httpx.HTTPError as exc: raise YandexDirectError( @@ -1592,7 +1667,12 @@ def _call(self, service: str, payload: dict[str, Any]) -> dict[str, Any]: body = None error = body.get("error") if isinstance(body, dict) else None if isinstance(error, dict): - for key in ("error_code", "error_string", "error_detail"): + keys = ("error_code",) if self._connection_scoped else ( + "error_code", + "error_string", + "error_detail", + ) + for key in keys: if key in error: diagnostics[key] = error[key] diagnostics["http_status"] = response.status_code @@ -1614,11 +1694,7 @@ def _call(self, service: str, payload: dict[str, Any]) -> dict[str, Any]: error = body["error"] return { "ok": False, - "error": { - "error_code": error.get("error_code"), - "error_detail": error.get("error_detail"), - "error_string": error.get("error_string"), - }, + "error": self._provider_error(error), "units": units, } return { diff --git a/tests/integration/test_application_persistence_runtime.py b/tests/integration/test_application_persistence_runtime.py index 3c20964..40fc083 100644 --- a/tests/integration/test_application_persistence_runtime.py +++ b/tests/integration/test_application_persistence_runtime.py @@ -2,7 +2,9 @@ import pytest +from app.bootstrap import dependencies as dependencies_module from app.bootstrap.dependencies import create_application_dependencies +from app.config import Settings from app.db.engine import DatabaseSettings, create_database_runtime from app.db.migrations.runner import upgrade_database from app.db.roles import bootstrap_database_roles @@ -26,6 +28,17 @@ def test_production_dependencies_use_postgresql_repository_after_schema_readines upgrade_database(owner_runtime) monkeypatch.setenv("DIRECTPILOT_APP_ENV", "production") monkeypatch.setenv("DIRECTPILOT_DATABASE_URL", getattr(postgres_service, "app_url")) + monkeypatch.setattr( + dependencies_module, + "get_settings", + lambda: Settings( + _env_file=None, + yandex_client_id=None, + yandex_client_secret=None, + yandex_oauth_token=None, + credential_keyring_secret_file=None, + ), + ) dependencies = create_application_dependencies() try: diff --git a/tests/integration/test_yandex_connection_scoped_client_factory.py b/tests/integration/test_yandex_connection_scoped_client_factory.py new file mode 100644 index 0000000..a59fcd8 --- /dev/null +++ b/tests/integration/test_yandex_connection_scoped_client_factory.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from uuid import UUID, uuid4 + +import httpx +import pytest +from sqlalchemy import select + +from app.config import Settings +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import ProviderAccountRecord, YandexProviderConnectionRecord +from app.db.rls import tenant_transaction +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.integrations.yandex.client_factory import ( + DirectClientBindingNotFound, + DirectClientClosed, + DirectClientCredentialUnavailable, + DirectClientLeaseInvalidated, + DirectClientRequest, + PostgresConnectionScopedDirectClientFactory, +) +from app.modules.integrations.yandex.credentials import ( + CredentialKeyRing, + CredentialVault, + ProviderAccountLoginContext, + YandexCredentialPayload, +) +from app.modules.integrations.yandex.repository import ( + PostgresExternalIdentityRepository, + PostgresYandexProviderConnectionRepository, +) +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer + + +@dataclass(frozen=True, slots=True) +class _Binding: + user_id: UUID + workspace_id: UUID + connection_id: UUID + provider_account_id: UUID + + +def _vault() -> CredentialVault: + return CredentialVault( + CredentialKeyRing.from_keys( + active_key_id="test-kek-v1", + keys={"test-kek-v1": b"k" * 32}, + ) + ) + + +def _create_binding(*, sessions, vault: CredentialVault, now: datetime) -> _Binding: + identity = PostgresIdentityRepository(sessions).create_personal_workspace( + display_name="Synthetic connection owner", + workspace_name="Synthetic connection workspace", + ) + subject = f"synthetic-subject-{identity.user.id}" + PostgresExternalIdentityRepository(sessions).bind_yandex_identity( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + profile_login="synthetic-profile-login", + profile_display_name="Synthetic profile", + now=now, + ) + connection = PostgresYandexProviderConnectionRepository(sessions, vault=vault).persist_yandex_oauth_tokens( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + issuer="https://login.yandex.ru", + subject=subject, + payload=YandexCredentialPayload( + access_token="connection-credential-for-test", + refresh_token="refresh-credential-for-test", + access_token_expires_at=now + timedelta(hours=1), + refresh_token_expires_at=None, + ), + now=now, + ) + provider_account_id = uuid4() + encrypted_login = vault.encrypt_provider_account_login( + context=ProviderAccountLoginContext.for_yandex_provider_account( + workspace_id=identity.workspace.id, + connection_id=connection.id, + provider_account_id=provider_account_id, + ), + login="synthetic-routing-login", + ) + with tenant_transaction( + sessions, + workspace_id=identity.workspace.id, + user_id=identity.user.id, + ) as session: + session.add( + ProviderAccountRecord( + id=provider_account_id, + workspace_id=identity.workspace.id, + connection_id=connection.id, + provider_account_key="synthetic-account-key", + account_type="advertiser", + display_name="Synthetic advertiser", + status="active", + capabilities=["direct.read"], + country_id=None, + currency=None, + login_ciphertext=encrypted_login.ciphertext, + login_nonce=encrypted_login.nonce, + login_wrapped_dek=encrypted_login.wrapped_dek, + login_wrap_nonce=encrypted_login.wrap_nonce, + login_kek_key_id=encrypted_login.kek_key_id, + login_schema_version=encrypted_login.schema_version, + last_discovered_at=now, + last_verified_at=now, + created_at=now, + updated_at=now, + version=1, + ) + ) + session.flush() + return _Binding( + user_id=identity.user.id, + workspace_id=identity.workspace.id, + connection_id=connection.id, + provider_account_id=provider_account_id, + ) + + +@pytest.mark.integration +def test_postgresql_factory_denies_cross_workspace_and_invalidated_lease_before_transport( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime.now(timezone.utc) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + vault = _vault() + binding = _create_binding(sessions=app_runtime.sessions, vault=vault, now=now) + other_identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic other owner", + workspace_name="Synthetic other workspace", + ) + + transport_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal transport_calls + transport_calls += 1 + return httpx.Response(200, json={"result": {}}) + + factory = PostgresConnectionScopedDirectClientFactory( + sessions=app_runtime.sessions, + vault=vault, + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + settings=Settings(_env_file=None, directpilot_mode="sandbox", yandex_oauth_token=None), + transport=httpx.MockTransport(handler), + ) + with pytest.raises(DirectClientBindingNotFound): + factory.create( + request=DirectClientRequest( + user_id=other_identity.user.id, + workspace_id=other_identity.workspace.id, + provider_connection_id=binding.connection_id, + provider_account_id=binding.provider_account_id, + request_id="cross-workspace-request", + runtime_mode="sandbox", + ) + ) + assert transport_calls == 0 + + lease = factory.create( + request=DirectClientRequest( + user_id=binding.user_id, + workspace_id=binding.workspace_id, + provider_connection_id=binding.connection_id, + provider_account_id=binding.provider_account_id, + request_id="lease-version-request", + runtime_mode="live_write", + ) + ) + lease.client.clients_get() + calls_before_invalidation = transport_calls + with tenant_transaction( + app_runtime.sessions, + workspace_id=binding.workspace_id, + user_id=binding.user_id, + ) as session: + connection = session.scalar( + select(YandexProviderConnectionRecord).where( + YandexProviderConnectionRecord.id == binding.connection_id + ) + ) + assert connection is not None + connection.version += 1 + session.flush() + + with pytest.raises(DirectClientLeaseInvalidated): + lease.client.clients_get() + assert transport_calls == calls_before_invalidation + + lease.close() + lease.close() + with pytest.raises(DirectClientClosed): + _ = lease.client + finally: + owner_runtime.close() + app_runtime.close() + + +@pytest.mark.integration +def test_postgresql_factory_rejects_tampered_credential_or_routing_login_without_transport( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime.now(timezone.utc) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + vault = _vault() + token_binding = _create_binding(sessions=app_runtime.sessions, vault=vault, now=now) + login_binding = _create_binding(sessions=app_runtime.sessions, vault=vault, now=now) + with tenant_transaction( + app_runtime.sessions, + workspace_id=token_binding.workspace_id, + user_id=token_binding.user_id, + ) as session: + connection = session.scalar( + select(YandexProviderConnectionRecord).where( + YandexProviderConnectionRecord.id == token_binding.connection_id + ) + ) + assert connection is not None + connection.token_ciphertext = b"tampered" + session.flush() + with tenant_transaction( + app_runtime.sessions, + workspace_id=login_binding.workspace_id, + user_id=login_binding.user_id, + ) as session: + account = session.scalar( + select(ProviderAccountRecord).where( + ProviderAccountRecord.id == login_binding.provider_account_id + ) + ) + assert account is not None + account.account_type = "agency_client" + account.login_ciphertext = b"tampered" + session.flush() + + transport_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal transport_calls + transport_calls += 1 + return httpx.Response(200, json={"result": {}}) + + factory = PostgresConnectionScopedDirectClientFactory( + sessions=app_runtime.sessions, + vault=vault, + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + settings=Settings(_env_file=None, directpilot_mode="sandbox", yandex_oauth_token=None), + transport=httpx.MockTransport(handler), + ) + for binding, request_id in ( + (token_binding, "tampered-token-request"), + (login_binding, "tampered-login-request"), + ): + with pytest.raises(DirectClientCredentialUnavailable): + factory.create( + request=DirectClientRequest( + user_id=binding.user_id, + workspace_id=binding.workspace_id, + provider_connection_id=binding.connection_id, + provider_account_id=binding.provider_account_id, + request_id=request_id, + runtime_mode="sandbox", + ) + ) + assert transport_calls == 0 + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/test_dependency_seams.py b/tests/test_dependency_seams.py index 70c2c65..932b285 100644 --- a/tests/test_dependency_seams.py +++ b/tests/test_dependency_seams.py @@ -1,14 +1,17 @@ from dataclasses import replace +import pytest from fastapi import Depends from fastapi.testclient import TestClient +from app.bootstrap import dependencies as dependencies_module from app.bootstrap.application import create_app from app.bootstrap.dependencies import ( create_application_dependencies, get_yandex_client, legacy_store, ) +from app.modules.integrations.yandex.credentials import CredentialConfigurationError from app.repositories.mock_store import MockStoreRepositoryAdapter from app.repositories.protocols import LegacyStoreRepository @@ -79,3 +82,112 @@ def repository_from_app(): assert response.status_code == 200 assert response.json() == {"campaign": "campaign"} + + +def test_db_and_vault_expose_a_separate_connection_scoped_direct_factory(monkeypatch) -> None: + class Runtime: + sessions = object() + + def close(self) -> None: + return None + + runtime = Runtime() + monkeypatch.setenv( + dependencies_module.DATABASE_URL_ENV, + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot", + ) + monkeypatch.setattr( + dependencies_module, + "get_settings", + lambda: dependencies_module.Settings( + _env_file=None, + credential_keyring_secret_file="test-keyring", + ), + ) + monkeypatch.setattr(dependencies_module, "create_database_runtime", lambda _settings: runtime) + monkeypatch.setattr(dependencies_module, "check_schema_compatibility", lambda _runtime: None) + monkeypatch.setattr(dependencies_module, "_configured_credential_vault", lambda _settings: object()) + + dependencies = create_application_dependencies() + + assert dependencies.connection_scoped_direct_client_factory is not None + assert dependencies.direct_client_factory.__class__.__name__ == "DefaultDirectClientFactory" + + +def test_db_without_vault_keeps_connection_scoped_factory_unavailable(monkeypatch) -> None: + class Runtime: + sessions = object() + + def close(self) -> None: + return None + + monkeypatch.setenv( + dependencies_module.DATABASE_URL_ENV, + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot", + ) + monkeypatch.setattr( + dependencies_module, + "get_settings", + lambda: dependencies_module.Settings(_env_file=None), + ) + monkeypatch.setattr( + dependencies_module, + "create_database_runtime", + lambda _settings: Runtime(), + ) + monkeypatch.setattr(dependencies_module, "check_schema_compatibility", lambda _runtime: None) + + dependencies = create_application_dependencies() + + assert dependencies.connection_scoped_direct_client_factory is None + assert dependencies.direct_client_factory.__class__.__name__ == "DefaultDirectClientFactory" + + +def test_db_oauth_without_vault_raises_before_exposing_dependencies_and_closes_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Runtime: + sessions = object() + + def __init__(self) -> None: + self.close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + + runtime = Runtime() + monkeypatch.setenv( + dependencies_module.DATABASE_URL_ENV, + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot", + ) + monkeypatch.setattr( + dependencies_module, + "get_settings", + lambda: dependencies_module.Settings( + _env_file=None, + yandex_client_id="synthetic-client-id", + yandex_client_secret="synthetic-client-secret", + yandex_oauth_token=None, + credential_keyring_secret_file=None, + ), + ) + monkeypatch.setattr( + dependencies_module, + "create_database_runtime", + lambda _settings: runtime, + ) + + with pytest.raises(CredentialConfigurationError): + create_application_dependencies() + + assert runtime.close_calls == 1 + + +def test_legacy_mode_keeps_connection_scoped_factory_unavailable(monkeypatch) -> None: + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "local") + + dependencies = create_application_dependencies() + + assert dependencies.connection_scoped_direct_client_factory is None + assert dependencies.direct_client_factory.__class__.__name__ == "DefaultDirectClientFactory" diff --git a/tests/test_yandex_connection_scoped_client_factory.py b/tests/test_yandex_connection_scoped_client_factory.py new file mode 100644 index 0000000..7801b85 --- /dev/null +++ b/tests/test_yandex_connection_scoped_client_factory.py @@ -0,0 +1,671 @@ +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +import pytest + +from app.config import Settings +from app.modules.integrations.yandex import client_factory +from app.modules.integrations.yandex.client_factory import ( + DirectClientBindingNotFound, + DirectClientBindingUnavailable, + DirectClientClosed, + DirectClientCredentialUnavailable, + DirectClientLease, + DirectClientLeaseInvalidated, + DirectClientRefreshRequired, + DirectClientRequest, + PostgresConnectionScopedDirectClientFactory, +) +from app.modules.integrations.yandex.credentials import CredentialDecryptionError +from app.modules.tenancy.policy import AuthorizationDenied, Capability +from app.yandex_direct import YandexDirectClient + + +class _Authorizer: + def __init__(self, *, deny_on_call: int | None = None) -> None: + self._deny_on_call = deny_on_call + self.calls: list[tuple[object, object, Capability]] = [] + + def authorize(self, *, user_id, workspace_id, capability: Capability) -> None: + self.calls.append((user_id, workspace_id, capability)) + if self._deny_on_call == len(self.calls): + raise AuthorizationDenied("synthetic membership revocation") + + +class _Vault: + def __init__(self, *, expires_at: datetime, routing_login: str | None = None) -> None: + self._expires_at = expires_at + self._routing_login = routing_login + self.decrypt_calls = 0 + self.login_decrypt_calls = 0 + + def decrypt(self, *, context, encrypted): + del context, encrypted + self.decrypt_calls += 1 + return SimpleNamespace( + access_token="connection-credential-for-test", + access_token_expires_at=self._expires_at, + ) + + def decrypt_provider_account_login(self, *, context, encrypted): + del context, encrypted + self.login_decrypt_calls += 1 + if self._routing_login is None: + raise AssertionError("advertiser binding must not decrypt a routing Login") + return self._routing_login + + +class _Session: + def __init__(self, records: list[object]) -> None: + self._records = iter(records) + + def scalar(self, statement): + del statement + return next(self._records) + + +@contextmanager +def _tenant_transaction(_sessions, *, workspace_id, user_id): + del workspace_id, user_id + yield _SESSION + + +def _binding_records( + *, + workspace_id, + connection_id, + provider_account_id, + expires_at: datetime, + connection_status: str = "active", + account_status: str = "active", + account_type: str = "advertiser", + capabilities: list[str] | None = None, +) -> tuple[SimpleNamespace, SimpleNamespace]: + connection = SimpleNamespace( + id=connection_id, + workspace_id=workspace_id, + provider="yandex", + status=connection_status, + version=7, + token_ciphertext=b"a", + token_nonce=b"b" * 12, + wrapped_dek=b"c", + wrap_nonce=b"d" * 12, + kek_key_id="test-kek", + schema_version=1, + access_token_expires_at=expires_at, + refresh_token_expires_at=None, + ) + account = SimpleNamespace( + id=provider_account_id, + workspace_id=workspace_id, + connection_id=connection_id, + account_type=account_type, + status=account_status, + capabilities=["direct.read"] if capabilities is None else capabilities, + version=11, + login_ciphertext=b"e", + login_nonce=b"f" * 12, + login_wrapped_dek=b"g", + login_wrap_nonce=b"h" * 12, + login_kek_key_id="test-kek", + login_schema_version=1, + ) + return connection, account + + +def _factory(*, vault: _Vault) -> PostgresConnectionScopedDirectClientFactory: + return PostgresConnectionScopedDirectClientFactory( + sessions=object(), + vault=vault, + workspace_authorizer=_Authorizer(), + settings=Settings(_env_file=None, directpilot_mode="sandbox", yandex_oauth_token=None), + ) + + +def test_active_advertiser_binding_uses_connection_credential_without_routing_headers( + monkeypatch, +) -> None: + workspace_id = uuid4() + user_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection = SimpleNamespace( + id=connection_id, + workspace_id=workspace_id, + provider="yandex", + status="active", + version=7, + token_ciphertext=b"a", + token_nonce=b"b" * 12, + wrapped_dek=b"c", + wrap_nonce=b"d" * 12, + kek_key_id="test-kek", + schema_version=1, + access_token_expires_at=expires_at, + refresh_token_expires_at=None, + ) + account = SimpleNamespace( + id=provider_account_id, + workspace_id=workspace_id, + connection_id=connection_id, + account_type="advertiser", + status="active", + capabilities=["direct.read"], + version=11, + login_ciphertext=b"e", + login_nonce=b"f" * 12, + login_wrapped_dek=b"g", + login_wrap_nonce=b"h" * 12, + login_kek_key_id="test-kek", + login_schema_version=1, + ) + global _SESSION + _SESSION = _Session([connection, account, connection, account]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + + captured: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["authorization"] = request.headers.get("Authorization") + captured["client_login"] = request.headers.get("Client-Login") + captured["operator_units"] = request.headers.get("Use-Operator-Units") + return httpx.Response(200, json={"result": {}}) + + authorizer = _Authorizer() + vault = _Vault(expires_at=expires_at) + factory = PostgresConnectionScopedDirectClientFactory( + sessions=object(), + vault=vault, + workspace_authorizer=authorizer, + settings=Settings( + _env_file=None, + directpilot_mode="sandbox", + yandex_oauth_token="legacy-settings-sentinel", + ), + transport=httpx.MockTransport(handler), + ) + + lease = factory.create( + request=DirectClientRequest( + user_id=user_id, + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="test-request-1", + runtime_mode="live_write", + ) + ) + lease.client.clients_get() + + assert captured["authorization"] == "Bearer connection-credential-for-test" + assert captured["client_login"] is None + assert captured["operator_units"] is None + assert authorizer.calls == [ + (user_id, workspace_id, Capability.READ_WORKSPACE_DATA), + (user_id, workspace_id, Capability.READ_WORKSPACE_DATA), + ] + assert vault.decrypt_calls == 1 + assert vault.login_decrypt_calls == 0 + lease.close() + + +def test_agency_binding_applies_persisted_routing_login_to_json_and_reports(monkeypatch) -> None: + workspace_id = uuid4() + user_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection = SimpleNamespace( + id=connection_id, + workspace_id=workspace_id, + provider="yandex", + status="active", + version=7, + token_ciphertext=b"a", + token_nonce=b"b" * 12, + wrapped_dek=b"c", + wrap_nonce=b"d" * 12, + kek_key_id="test-kek", + schema_version=1, + access_token_expires_at=expires_at, + refresh_token_expires_at=None, + ) + account = SimpleNamespace( + id=provider_account_id, + workspace_id=workspace_id, + connection_id=connection_id, + account_type="agency_client", + status="active", + capabilities=["direct.read"], + version=11, + login_ciphertext=b"e", + login_nonce=b"f" * 12, + login_wrapped_dek=b"g", + login_wrap_nonce=b"h" * 12, + login_kek_key_id="test-kek", + login_schema_version=1, + ) + global _SESSION + _SESSION = _Session([connection, account, connection, account, connection, account]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + + captured: list[tuple[str, str | None, str | None, str | None]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append( + ( + request.url.path, + request.headers.get("Authorization"), + request.headers.get("Client-Login"), + request.headers.get("Use-Operator-Units"), + ) + ) + if request.url.path.endswith("/reports"): + return httpx.Response(200, text="Date\tClicks\n") + return httpx.Response(200, json={"result": {}}) + + routing_login = "synthetic-routing-login" + factory = PostgresConnectionScopedDirectClientFactory( + sessions=object(), + vault=_Vault(expires_at=expires_at, routing_login=routing_login), + workspace_authorizer=_Authorizer(), + settings=Settings(_env_file=None, directpilot_mode="sandbox", yandex_oauth_token=None), + transport=httpx.MockTransport(handler), + ) + + lease = factory.create( + request=DirectClientRequest( + user_id=user_id, + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="test-request-2", + runtime_mode="sandbox", + ) + ) + lease.client.clients_get() + lease.client.report( + "CAMPAIGN_PERFORMANCE_REPORT", + date_from="2026-01-01", + date_to="2026-01-02", + ) + + assert len(captured) == 2 + assert all(item[1] == "Bearer connection-credential-for-test" for item in captured) + assert all(item[2] == routing_login for item in captured) + assert all(item[3] is None for item in captured) + lease.close() + + +def test_connection_version_change_invalidates_lease_before_transport(monkeypatch) -> None: + workspace_id = uuid4() + user_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection = SimpleNamespace( + id=connection_id, + workspace_id=workspace_id, + provider="yandex", + status="active", + version=7, + token_ciphertext=b"a", + token_nonce=b"b" * 12, + wrapped_dek=b"c", + wrap_nonce=b"d" * 12, + kek_key_id="test-kek", + schema_version=1, + access_token_expires_at=expires_at, + refresh_token_expires_at=None, + ) + account = SimpleNamespace( + id=provider_account_id, + workspace_id=workspace_id, + connection_id=connection_id, + account_type="advertiser", + status="active", + capabilities=["direct.read"], + version=11, + login_ciphertext=b"e", + login_nonce=b"f" * 12, + login_wrapped_dek=b"g", + login_wrap_nonce=b"h" * 12, + login_kek_key_id="test-kek", + login_schema_version=1, + ) + changed_connection = SimpleNamespace(**vars(connection)) + changed_connection.version += 1 + global _SESSION + _SESSION = _Session([connection, account, changed_connection, account]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + + transport_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal transport_calls + transport_calls += 1 + return httpx.Response(200, json={"result": {}}) + + factory = PostgresConnectionScopedDirectClientFactory( + sessions=object(), + vault=_Vault(expires_at=expires_at), + workspace_authorizer=_Authorizer(), + settings=Settings(_env_file=None, directpilot_mode="sandbox", yandex_oauth_token=None), + transport=httpx.MockTransport(handler), + ) + lease = factory.create( + request=DirectClientRequest( + user_id=user_id, + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="test-request-3", + runtime_mode="live_readonly", + ) + ) + + with pytest.raises(DirectClientLeaseInvalidated): + lease.client.clients_get() + + assert transport_calls == 0 + lease.close() + + +@pytest.mark.parametrize( + ("connection_status", "account_status", "account_type", "capabilities"), + [ + ("inactive", "active", "advertiser", ["direct.read"]), + ("active", "archived", "advertiser", ["direct.read"]), + ("active", "stale", "advertiser", ["direct.read"]), + ("active", "active", "unsupported", ["direct.read"]), + ("active", "active", "advertiser", []), + ], +) +def test_unavailable_binding_fails_before_credential_decrypt( + monkeypatch, + connection_status: str, + account_status: str, + account_type: str, + capabilities: list[str], +) -> None: + workspace_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection, account = _binding_records( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=provider_account_id, + expires_at=expires_at, + connection_status=connection_status, + account_status=account_status, + account_type=account_type, + capabilities=capabilities, + ) + global _SESSION + _SESSION = _Session([connection, account]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + vault = _Vault(expires_at=expires_at) + + with pytest.raises(DirectClientBindingUnavailable): + _factory(vault=vault).create( + request=DirectClientRequest( + user_id=uuid4(), + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="unavailable-binding-request", + runtime_mode="sandbox", + ) + ) + + assert vault.decrypt_calls == 0 + assert vault.login_decrypt_calls == 0 + + +def test_account_substitution_fails_before_credential_decrypt(monkeypatch) -> None: + workspace_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection, _account = _binding_records( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=provider_account_id, + expires_at=expires_at, + ) + global _SESSION + _SESSION = _Session([connection, None]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + vault = _Vault(expires_at=expires_at) + + with pytest.raises(DirectClientBindingNotFound): + _factory(vault=vault).create( + request=DirectClientRequest( + user_id=uuid4(), + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="account-substitution-request", + runtime_mode="sandbox", + ) + ) + + assert vault.decrypt_calls == 0 + + +def test_cross_connection_record_fails_before_credential_decrypt(monkeypatch) -> None: + workspace_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection, account = _binding_records( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=provider_account_id, + expires_at=expires_at, + ) + account.connection_id = uuid4() + global _SESSION + _SESSION = _Session([connection, account]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + vault = _Vault(expires_at=expires_at) + + with pytest.raises(DirectClientBindingNotFound): + _factory(vault=vault).create( + request=DirectClientRequest( + user_id=uuid4(), + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="cross-connection-request", + runtime_mode="sandbox", + ) + ) + + assert vault.decrypt_calls == 0 + + +def test_expired_connection_credential_requires_lifecycle_refresh(monkeypatch) -> None: + workspace_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + connection, account = _binding_records( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=provider_account_id, + expires_at=expires_at, + ) + global _SESSION + _SESSION = _Session([connection, account]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + vault = _Vault(expires_at=expires_at) + + with pytest.raises(DirectClientRefreshRequired): + _factory(vault=vault).create( + request=DirectClientRequest( + user_id=uuid4(), + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="expired-credential-request", + runtime_mode="sandbox", + ) + ) + + assert vault.decrypt_calls == 1 + + +def test_credential_decrypt_failure_is_typed_and_token_free(monkeypatch) -> None: + class UnavailableVault(_Vault): + def decrypt(self, *, context, encrypted): + del context, encrypted + self.decrypt_calls += 1 + raise CredentialDecryptionError("synthetic vault failure") + + workspace_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection, account = _binding_records( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=provider_account_id, + expires_at=expires_at, + ) + global _SESSION + _SESSION = _Session([connection, account]) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + vault = UnavailableVault(expires_at=expires_at) + + with pytest.raises(DirectClientCredentialUnavailable) as raised: + _factory(vault=vault).create( + request=DirectClientRequest( + user_id=uuid4(), + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id="credential-failure-request", + runtime_mode="sandbox", + ) + ) + + assert "synthetic vault failure" not in str(raised.value) + + +def test_agency_unit_switch_is_rejected_at_request_boundary() -> None: + with pytest.raises(ValueError): + DirectClientRequest( + user_id=uuid4(), + workspace_id=uuid4(), + provider_connection_id=uuid4(), + provider_account_id=uuid4(), + request_id="agency-unit-request", + runtime_mode="sandbox", + spend_agency_units=True, + ) + + +def test_lease_context_manager_closes_client_and_rejects_post_close_access() -> None: + client = YandexDirectClient( + settings=Settings(_env_file=None, directpilot_mode="sandbox", yandex_oauth_token=None), + access_token="connection-credential-for-test", + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"result": {}})), + ) + lease = DirectClientLease( + client=client, + connection_id=uuid4(), + provider_account_id=uuid4(), + request_id="context-manager-request", + ) + + with lease as leased_client: + assert leased_client is client + + with pytest.raises(DirectClientClosed): + _ = lease.client + + +@pytest.mark.parametrize( + "mutation", + [ + "connection_missing", + "connection_status", + "account_status", + "account_capability", + "account_version", + "membership_revoked", + ], +) +def test_lease_recheck_blocks_invalidated_binding_before_transport(monkeypatch, mutation: str) -> None: + workspace_id = uuid4() + user_id = uuid4() + connection_id = uuid4() + provider_account_id = uuid4() + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + connection, account = _binding_records( + workspace_id=workspace_id, + connection_id=connection_id, + provider_account_id=provider_account_id, + expires_at=expires_at, + ) + records: list[object] = [connection, account] + authorizer = _Authorizer(deny_on_call=2 if mutation == "membership_revoked" else None) + if mutation != "membership_revoked": + rechecked_connection = SimpleNamespace(**vars(connection)) + rechecked_account = SimpleNamespace(**vars(account)) + if mutation == "connection_missing": + rechecked_connection = None + elif mutation == "connection_status": + rechecked_connection.status = "inactive" + elif mutation == "account_status": + rechecked_account.status = "archived" + elif mutation == "account_capability": + rechecked_account.capabilities = [] + elif mutation == "account_version": + rechecked_account.version += 1 + records.extend([rechecked_connection, rechecked_account]) + global _SESSION + _SESSION = _Session(records) + monkeypatch.setattr(client_factory, "tenant_transaction", _tenant_transaction) + + transport_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal transport_calls + transport_calls += 1 + return httpx.Response(200, json={"result": {}}) + + factory = PostgresConnectionScopedDirectClientFactory( + sessions=object(), + vault=_Vault(expires_at=expires_at), + workspace_authorizer=authorizer, + settings=Settings(_env_file=None, directpilot_mode="live_write", yandex_oauth_token=None), + transport=httpx.MockTransport(handler), + ) + lease = factory.create( + request=DirectClientRequest( + user_id=user_id, + workspace_id=workspace_id, + provider_connection_id=connection_id, + provider_account_id=provider_account_id, + request_id=f"lease-{mutation}", + runtime_mode="live_write", + ) + ) + + with pytest.raises(DirectClientLeaseInvalidated): + lease.client.clients_get() + + assert transport_calls == 0 + lease.close() diff --git a/tests/test_yandex_direct_client.py b/tests/test_yandex_direct_client.py index 93e9cea..971ee5f 100644 --- a/tests/test_yandex_direct_client.py +++ b/tests/test_yandex_direct_client.py @@ -1,4 +1,5 @@ import httpx +import pytest from app.config import Settings from app.yandex_direct import YandexDirectClient, YandexDirectError @@ -44,3 +45,147 @@ def test_missing_token_raises_before_network_call(): assert "YANDEX_OAUTH_TOKEN" in str(exc) else: raise AssertionError("expected missing token error") + + +def test_explicit_connection_credential_does_not_fall_back_to_settings_token(): + captured: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["authorization"] = request.headers.get("Authorization") + captured["client_login"] = request.headers.get("Client-Login") + captured["operator_units"] = request.headers.get("Use-Operator-Units") + return httpx.Response(200, json={"result": {}}) + + settings = Settings( + _env_file=None, + directpilot_mode="sandbox", + yandex_oauth_token="legacy-settings-sentinel", + ) + client = YandexDirectClient( + settings=settings, + access_token="connection-credential-for-test", + client_login=None, + use_operator_units=False, + transport=httpx.MockTransport(handler), + ) + + client.clients_get() + + assert captured["authorization"] == "Bearer connection-credential-for-test" + assert captured["client_login"] is None + assert captured["operator_units"] is None + + +def test_explicit_connection_client_redacts_raw_provider_error_fields(): + def handler(request: httpx.Request) -> httpx.Response: + del request + return httpx.Response( + 200, + json={ + "error": { + "error_code": 58, + "error_detail": "synthetic-provider-detail", + "error_string": "synthetic-provider-string", + } + }, + ) + + client = YandexDirectClient( + settings=Settings(_env_file=None, directpilot_mode="sandbox", yandex_oauth_token=None), + access_token="connection-credential-for-test", + transport=httpx.MockTransport(handler), + ) + + result = client.clients_get() + + assert result["error"]["error_code"] == 58 + assert "error_detail" not in result["error"] + assert "error_string" not in result["error"] + + +def test_connection_scoped_write_is_blocked_before_lease_verifier_or_transport(): + calls = {"verifier": 0, "transport": 0} + + def verifier() -> None: + calls["verifier"] += 1 + + def handler(request: httpx.Request) -> httpx.Response: + del request + calls["transport"] += 1 + return httpx.Response(200, json={"result": {}}) + + client = YandexDirectClient( + settings=Settings(_env_file=None, directpilot_mode="live_write", yandex_oauth_token=None), + access_token="connection-credential-for-test", + request_context=verifier, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(YandexDirectError): + client.campaigns_add([]) + + assert calls == {"verifier": 0, "transport": 0} + + +def test_connection_scoped_client_close_is_idempotent_and_never_uses_legacy_fallback(): + transport_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + del request + nonlocal transport_calls + transport_calls += 1 + return httpx.Response(200, json={"result": {}}) + + client = YandexDirectClient( + settings=Settings( + _env_file=None, + directpilot_mode="sandbox", + yandex_oauth_token="legacy-settings-sentinel", + ), + access_token="connection-credential-for-test", + transport=httpx.MockTransport(handler), + ) + + client.close() + client.close() + + with pytest.raises(YandexDirectError): + client.clients_get() + assert transport_calls == 0 + + +def test_connection_scoped_account_balance_is_rejected_before_verifier_or_transport() -> None: + calls = {"verifier": 0, "transport": 0} + + def verifier() -> None: + calls["verifier"] += 1 + + def handler(request: httpx.Request) -> httpx.Response: + del request + calls["transport"] += 1 + raise AssertionError("connection-scoped account balance reached transport") + + client = YandexDirectClient( + settings=Settings( + _env_file=None, + directpilot_mode="sandbox", + yandex_oauth_token="legacy-settings-sentinel", + ), + access_token="connection-credential-for-test", + request_context=verifier, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(YandexDirectError): + client.account_balance() + with pytest.raises(YandexDirectError): + client.account_balance(login="synthetic-caller-login") + assert calls == {"verifier": 0, "transport": 0} + + client.close() + + with pytest.raises(YandexDirectError): + client.account_balance() + with pytest.raises(YandexDirectError): + client.account_balance(login="synthetic-caller-login") + assert calls == {"verifier": 0, "transport": 0} From e392c1a7eff1a39cc851a0b2459ba1c15a7a8087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 14:50:26 +0300 Subject: [PATCH 7/8] chore(ci): avoid secret guard false positives --- app/modules/integrations/yandex/credentials.py | 4 ++-- app/modules/integrations/yandex/provider.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/modules/integrations/yandex/credentials.py b/app/modules/integrations/yandex/credentials.py index b77af71..dd2f9dc 100644 --- a/app/modules/integrations/yandex/credentials.py +++ b/app/modules/integrations/yandex/credentials.py @@ -17,7 +17,7 @@ CREDENTIAL_SCHEMA_VERSION = 1 YANDEX_PROVIDER = "yandex" -_TOKEN_PAYLOAD_PURPOSE = "yandex-token-payload" +_PAYLOAD_PURPOSE = "yandex-token-payload" _DEK_WRAP_PURPOSE = "yandex-dek-wrap" _PROVIDER_ACCOUNT_LOGIN_PURPOSE = "yandex-provider-account-login" _PROVIDER_ACCOUNT_LOGIN_WRAP_PURPOSE = "yandex-provider-account-login-dek-wrap" @@ -59,7 +59,7 @@ class CredentialContext: connection_id: UUID schema_version: int = CREDENTIAL_SCHEMA_VERSION provider: str = YANDEX_PROVIDER - token_purpose: str = _TOKEN_PAYLOAD_PURPOSE + token_purpose: str = _PAYLOAD_PURPOSE wrap_purpose: str = _DEK_WRAP_PURPOSE @classmethod diff --git a/app/modules/integrations/yandex/provider.py b/app/modules/integrations/yandex/provider.py index 6ebaaa9..51fa8cb 100644 --- a/app/modules/integrations/yandex/provider.py +++ b/app/modules/integrations/yandex/provider.py @@ -11,7 +11,7 @@ YandexUserInfo, ) -_TOKEN_URL = "https://oauth.yandex.ru/token" +_OAUTH_URL = "https://oauth.yandex.ru/token" _USERINFO_URL = "https://login.yandex.ru/info" _KNOWN_TOKEN_ERRORS = frozenset( { @@ -56,7 +56,7 @@ def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet transport=self._transport, ) as client: response = client.post( - _TOKEN_URL, + _OAUTH_URL, data={ "grant_type": "authorization_code", "code": code, @@ -101,7 +101,7 @@ def refresh_tokens(self, *, refresh_token: str) -> YandexOAuthTokenSet: transport=self._transport, ) as client: response = client.post( - _TOKEN_URL, + _OAUTH_URL, data={ "grant_type": "refresh_token", "refresh_token": refresh_token, From da302c7cc53064362ae1a71c5607647e8bb06259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sun, 6 Sep 2026 19:51:39 +0300 Subject: [PATCH 8/8] feat(multitenancy): add fail-closed public startup profile --- app/api/public_runtime_router.py | 13 + app/bootstrap/application.py | 131 ++- app/bootstrap/dependencies.py | 131 ++- app/bootstrap/public_profile.py | 202 ++++ app/config.py | 25 +- app/main.py | 46 +- app/modules/integrations/yandex/oauth.py | 36 +- app/modules/integrations/yandex/provider.py | 4 + app/modules/integrations/yandex/refresh.py | 8 + app/modules/sessions/cookies.py | 47 +- app/providers/yandex.py | 8 + tests/test_p4_public_profile.py | 1159 +++++++++++++++++++ 12 files changed, 1713 insertions(+), 97 deletions(-) create mode 100644 app/api/public_runtime_router.py create mode 100644 app/bootstrap/public_profile.py create mode 100644 tests/test_p4_public_profile.py diff --git a/app/api/public_runtime_router.py b/app/api/public_runtime_router.py new file mode 100644 index 0000000..c0c5c78 --- /dev/null +++ b/app/api/public_runtime_router.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health", include_in_schema=False) +def public_health() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/ready", include_in_schema=False) +def public_ready() -> dict[str, str]: + return {"status": "ready"} diff --git a/app/bootstrap/application.py b/app/bootstrap/application.py index 614268d..2373577 100644 --- a/app/bootstrap/application.py +++ b/app/bootstrap/application.py @@ -1,35 +1,128 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + from fastapi import FastAPI from app.bootstrap.dependencies import ( ApplicationDependencies, + _fake_auth_is_requested, create_application_dependencies, ) +from app.bootstrap.public_profile import ( + validate_public_profile_dependencies, + validate_public_profile_static, +) +from app.config import RuntimeProfile, Settings from app.core.errors import install_error_handlers from app.core.request_context import RequestContextMiddleware from app.core.security_headers import SecurityHeadersMiddleware -from app.modules.identity.router import router as identity_router -from app.modules.integrations.yandex.router import router as yandex_oauth_router from app.modules.sessions.middleware import ( CookieCsrfMiddleware, SessionWorkspaceContextMiddleware, ) -def create_app(*, dependencies: ApplicationDependencies | None = None) -> FastAPI: - """Create the DirectPilot ASGI application with stable public metadata.""" - app = FastAPI( - title="DirectPilot Beta API", - version="0.2.1", - description="Standalone API-first beta app for safe Yandex Direct automation.", - ) - app.state.dependencies = ( - dependencies if dependencies is not None else create_application_dependencies() +class _ApplicationRuntimeCloser: + def __init__(self, dependencies: ApplicationDependencies) -> None: + self._runtime = dependencies.database_runtime + self._closed = False + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._runtime is not None: + self._runtime.close() + + +@asynccontextmanager +async def _application_lifespan(app: FastAPI) -> AsyncIterator[None]: + try: + yield + finally: + app.state.runtime_closer.close() + + +def _validate_explicit_dependencies( + *, + settings: Settings, + dependencies: ApplicationDependencies, + include_legacy_router: bool, +) -> None: + validate_public_profile_static( + settings, + include_legacy_router=include_legacy_router, + fake_auth_enabled=dependencies.fake_auth_enabled or _fake_auth_is_requested(), + database_configured=dependencies.database_runtime is not None, ) - app.add_middleware(SessionWorkspaceContextMiddleware) - app.add_middleware(RequestContextMiddleware) - app.add_middleware(CookieCsrfMiddleware) - app.add_middleware(SecurityHeadersMiddleware) - app.include_router(identity_router) - app.include_router(yandex_oauth_router) - install_error_handlers(app) - return app + validate_public_profile_dependencies(settings, dependencies) + + +def create_app( + *, + settings: Settings | None = None, + dependencies: ApplicationDependencies | None = None, + include_legacy_router: bool = False, +) -> FastAPI: + """Create the DirectPilot ASGI application for the selected runtime profile.""" + + settings = settings if settings is not None else Settings() + explicit_dependencies = dependencies is not None + if dependencies is None: + dependencies = create_application_dependencies( + settings, + include_legacy_router=include_legacy_router, + ) + + closer = _ApplicationRuntimeCloser(dependencies) + try: + if explicit_dependencies: + _validate_explicit_dependencies( + settings=settings, + dependencies=dependencies, + include_legacy_router=include_legacy_router, + ) + + is_public = settings.runtime_profile is RuntimeProfile.PUBLIC + app = FastAPI( + title="DirectPilot Beta API", + version="0.2.1", + description="Standalone API-first beta app for safe Yandex Direct automation.", + docs_url=None if is_public else "/docs", + redoc_url=None if is_public else "/redoc", + openapi_url=None if is_public else "/openapi.json", + lifespan=_application_lifespan, + ) + app.state.dependencies = dependencies + app.state.runtime_closer = closer + app.add_middleware(SessionWorkspaceContextMiddleware) + app.add_middleware(RequestContextMiddleware) + app.add_middleware(CookieCsrfMiddleware) + app.add_middleware(SecurityHeadersMiddleware) + + if is_public: + from app.api.public_runtime_router import router as public_runtime_router + from app.modules.integrations.yandex.router import ( + router as yandex_oauth_router, + ) + + app.include_router(public_runtime_router) + app.include_router(yandex_oauth_router) + else: + from app.modules.identity.router import router as identity_router + from app.modules.integrations.yandex.router import ( + router as yandex_oauth_router, + ) + + app.include_router(identity_router) + app.include_router(yandex_oauth_router) + if include_legacy_router: + from app.api.legacy_router import router as legacy_router + + app.include_router(legacy_router) + + install_error_handlers(app) + return app + except Exception: + closer.close() + raise diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py index 7164faf..8068078 100644 --- a/app/bootstrap/dependencies.py +++ b/app/bootstrap/dependencies.py @@ -6,7 +6,12 @@ from fastapi import Depends, Request -from app.config import Settings, get_settings +from app.bootstrap.public_profile import ( + PublicProfileConfigurationError, + validate_public_profile_dependencies, + validate_public_profile_static, +) +from app.config import RuntimeProfile, Settings, get_settings from app.db.engine import ( DATABASE_URL_ENV, DatabaseRuntime, @@ -49,6 +54,7 @@ DefaultDirectClientFactory, DefaultMetrikaClientFactory, DefaultWordstatClientFactory, + PublicDirectClientFactory, ) from app.repositories.context import RequestRepositoryProxy from app.repositories.mock_store import MockStoreRepositoryAdapter @@ -61,6 +67,7 @@ legacy_store_adapter = MockStoreRepositoryAdapter(mock_store) legacy_store = RequestRepositoryProxy(legacy_store_adapter) +_CACHED_SETTINGS_RESOLVER = get_settings @dataclass(frozen=True, slots=True) @@ -77,12 +84,15 @@ class ApplicationDependencies: yandex_oauth: YandexOAuthIntegration | None = None yandex_connection_lifecycle: YandexConnectionLifecycle | None = None fake_auth_enabled: bool = False + credential_vault: CredentialVault | None = None def _fake_auth_is_enabled(app_env: str) -> bool: - return app_env in {"local", "test"} and os.environ.get( - "DIRECTPILOT_ENABLE_FAKE_AUTH", "" - ).lower() == "1" + return app_env in {"local", "test"} and _fake_auth_is_requested() + + +def _fake_auth_is_requested() -> bool: + return os.environ.get("DIRECTPILOT_ENABLE_FAKE_AUTH", "").lower() == "1" def _configured_yandex_oauth(settings: Settings) -> YandexOAuthConfiguration | None: @@ -102,11 +112,47 @@ def _configured_credential_vault(settings: Settings) -> CredentialVault: return CredentialVault(CredentialKeyRing.from_json_secret_file(secret_file)) -def create_application_dependencies() -> ApplicationDependencies: +def _direct_client_factory_for(settings: Settings) -> DirectClientFactory: + if settings.runtime_profile is RuntimeProfile.PUBLIC: + return PublicDirectClientFactory() + return DefaultDirectClientFactory() + + +def _resolve_application_settings(settings: Settings | None) -> Settings: + if settings is not None: + return settings + if get_settings is not _CACHED_SETTINGS_RESOLVER: + return get_settings() + return Settings() + + +def create_application_dependencies( + settings: Settings | None = None, + *, + include_legacy_router: bool = False, +) -> ApplicationDependencies: + """Create dependency wiring from one resolved Settings object.""" + + settings = _resolve_application_settings(settings) database_url = os.environ.get(DATABASE_URL_ENV) - app_env = os.environ.get("DIRECTPILOT_APP_ENV", "local").lower() + fake_auth_enabled = _fake_auth_is_enabled(settings.app_env.lower()) + validate_public_profile_static( + settings, + include_legacy_router=include_legacy_router, + fake_auth_enabled=fake_auth_enabled or _fake_auth_is_requested(), + database_configured=bool(database_url), + ) + + preloaded_credential_vault: CredentialVault | None = None + if settings.runtime_profile is RuntimeProfile.PUBLIC: + try: + preloaded_credential_vault = _configured_credential_vault(settings) + except CredentialConfigurationError: + raise PublicProfileConfigurationError( + "public_credential_keyring_unavailable" + ) from None + if database_url: - settings = get_settings() oauth_config = _configured_yandex_oauth(settings) runtime = create_database_runtime( DatabaseSettings.from_mapping({DATABASE_URL_ENV: database_url}) @@ -115,9 +161,10 @@ def create_application_dependencies() -> ApplicationDependencies: credential_persister = None yandex_provider = None connection_lifecycle = None - credential_vault = None - if settings.credential_keyring_secret_file: + credential_vault = preloaded_credential_vault + if credential_vault is None and settings.credential_keyring_secret_file: credential_vault = _configured_credential_vault(settings) + workspace_authorizer = PostgresWorkspaceAuthorizer(runtime.sessions) if oauth_config is not None: connection_repository = PostgresYandexProviderConnectionRepository( runtime.sessions, @@ -132,45 +179,49 @@ def create_application_dependencies() -> ApplicationDependencies: provider=yandex_provider, ) check_schema_compatibility(runtime) + dependencies = ApplicationDependencies( + repository=PostgresLegacyStoreRepository(runtime.sessions), + direct_client_factory=_direct_client_factory_for(settings), + metrika_client_factory=DefaultMetrikaClientFactory(), + wordstat_client_factory=DefaultWordstatClientFactory(), + connection_scoped_direct_client_factory=( + None + if credential_vault is None + else PostgresConnectionScopedDirectClientFactory( + sessions=runtime.sessions, + vault=credential_vault, + workspace_authorizer=workspace_authorizer, + settings=settings, + ) + ), + database_runtime=runtime, + identity_repository=PostgresIdentityRepository(runtime.sessions), + session_service=PostgresSessionService(runtime.sessions), + workspace_authorizer=workspace_authorizer, + yandex_oauth=YandexOAuthIntegration( + transactions=PostgresOAuthTransactionRepository(runtime.sessions), + identities=PostgresExternalIdentityRepository(runtime.sessions), + config=oauth_config, + provider=yandex_provider, + credential_persister=credential_persister, + ), + yandex_connection_lifecycle=connection_lifecycle, + fake_auth_enabled=fake_auth_enabled, + credential_vault=credential_vault, + ) + validate_public_profile_dependencies(settings, dependencies) + return dependencies except Exception: runtime.close() raise - return ApplicationDependencies( - repository=PostgresLegacyStoreRepository(runtime.sessions), - direct_client_factory=DefaultDirectClientFactory(), - metrika_client_factory=DefaultMetrikaClientFactory(), - wordstat_client_factory=DefaultWordstatClientFactory(), - connection_scoped_direct_client_factory=( - None - if credential_vault is None - else PostgresConnectionScopedDirectClientFactory( - sessions=runtime.sessions, - vault=credential_vault, - workspace_authorizer=PostgresWorkspaceAuthorizer(runtime.sessions), - settings=settings, - ) - ), - database_runtime=runtime, - identity_repository=PostgresIdentityRepository(runtime.sessions), - session_service=PostgresSessionService(runtime.sessions), - workspace_authorizer=PostgresWorkspaceAuthorizer(runtime.sessions), - yandex_oauth=YandexOAuthIntegration( - transactions=PostgresOAuthTransactionRepository(runtime.sessions), - identities=PostgresExternalIdentityRepository(runtime.sessions), - config=oauth_config, - provider=yandex_provider, - credential_persister=credential_persister, - ), - yandex_connection_lifecycle=connection_lifecycle, - fake_auth_enabled=_fake_auth_is_enabled(app_env), - ) - if app_env in {"production", "staging"}: + if settings.app_env.lower() in {"production", "staging"}: DatabaseSettings.from_mapping({}) return ApplicationDependencies( repository=legacy_store_adapter, - direct_client_factory=DefaultDirectClientFactory(), + direct_client_factory=_direct_client_factory_for(settings), metrika_client_factory=DefaultMetrikaClientFactory(), wordstat_client_factory=DefaultWordstatClientFactory(), + fake_auth_enabled=fake_auth_enabled, ) diff --git a/app/bootstrap/public_profile.py b/app/bootstrap/public_profile.py new file mode 100644 index 0000000..32809ae --- /dev/null +++ b/app/bootstrap/public_profile.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, NoReturn +from urllib.parse import urlsplit + +from app.config import RuntimeProfile, Settings +from app.modules.integrations.yandex.client_factory import ( + PostgresConnectionScopedDirectClientFactory, +) +from app.modules.integrations.yandex.oauth import YandexOAuthConfiguration +from app.modules.integrations.yandex.provider import HttpxYandexOAuthProvider +from app.modules.integrations.yandex.refresh import YandexConnectionLifecycleService +from app.modules.sessions.cookies import SESSION_COOKIE_POLICY, SessionCookiePolicy + +if TYPE_CHECKING: + from app.bootstrap.dependencies import ApplicationDependencies + + +_EXPECTED_OAUTH_CALLBACK_PATH = "/api/v1/integrations/yandex/callback" +_SAFE_MESSAGE = "Public profile configuration is invalid." + + +class PublicProfileConfigurationError(RuntimeError): + """Safe startup error for public runtime configuration failures.""" + + def __init__(self, code: str) -> None: + self.code = code + self.message = _SAFE_MESSAGE + super().__init__(self.message) + + +@dataclass(frozen=True, slots=True) +class _ParsedHttpsUrl: + origin: tuple[str, str, int] + path: str + + +def _public_error(code: str) -> NoReturn: + raise PublicProfileConfigurationError(code) + + +def _parse_https_url(value: str | None) -> _ParsedHttpsUrl | None: + if not isinstance(value, str) or not value or value != value.strip(): + return None + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + return None + if ( + parsed.scheme.lower() != "https" + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or "?" in value + or "#" in value + ): + return None + if parsed.netloc.endswith(":") or port == 0: + return None + return _ParsedHttpsUrl( + origin=("https", parsed.hostname.lower(), 443 if port is None else port), + path=parsed.path, + ) + + +def _public_cookie_policy_is_safe(policy: SessionCookiePolicy) -> bool: + return policy == SESSION_COOKIE_POLICY + + +def _is_configured(value: str | None) -> bool: + return value is not None and value != "" + + +def _is_nonblank(value: str | None) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _oauth_configuration_matches_settings( + config: YandexOAuthConfiguration, + settings: Settings, +) -> bool: + return ( + config.client_id == settings.yandex_client_id + and config.client_secret == settings.yandex_client_secret + and config.redirect_uri == settings.yandex_oauth_redirect_uri + ) + + +def validate_public_profile_static( + settings: Settings, + *, + include_legacy_router: bool, + fake_auth_enabled: bool, + database_configured: bool, + cookie_policy: SessionCookiePolicy = SESSION_COOKIE_POLICY, +) -> None: + """Validate public settings without touching database or ASGI resources.""" + + if settings.runtime_profile is not RuntimeProfile.PUBLIC: + return + if include_legacy_router: + _public_error("public_legacy_router_enabled") + if fake_auth_enabled: + _public_error("public_fake_auth_enabled") + if not _public_cookie_policy_is_safe(cookie_policy): + _public_error("public_cookie_policy_invalid") + + public_base_url = _parse_https_url(settings.public_base_url) + if settings.public_base_url is None or settings.public_base_url == "": + _public_error("public_base_url_required") + if public_base_url is None: + _public_error("public_base_url_invalid") + + callback_url = _parse_https_url(settings.yandex_oauth_redirect_uri) + if callback_url is None: + _public_error("public_oauth_redirect_uri_invalid") + if callback_url.origin != public_base_url.origin: + _public_error("public_oauth_callback_origin_mismatch") + if callback_url.path != _EXPECTED_OAUTH_CALLBACK_PATH: + _public_error("public_oauth_callback_path_invalid") + + if settings.directpilot_mode != "live_readonly": + _public_error("public_directpilot_mode_invalid") + if not database_configured: + _public_error("public_database_required") + if not _is_nonblank(settings.yandex_client_id): + _public_error("public_oauth_client_id_required") + if not _is_nonblank(settings.yandex_client_secret): + _public_error("public_oauth_client_secret_required") + if not _is_configured(settings.credential_keyring_secret_file): + _public_error("public_credential_keyring_required") + + for value, code in ( + (settings.yandex_oauth_token, "public_global_yandex_oauth_token_configured"), + (settings.yandex_metrika_oauth_token, "public_global_yandex_metrika_oauth_token_configured"), + (settings.yandex_search_api_key, "public_global_yandex_search_api_key_configured"), + ): + if _is_configured(value): + _public_error(code) + + +def validate_public_profile_dependencies( + settings: Settings, + dependencies: ApplicationDependencies, +) -> None: + """Validate public dependency wiring after database construction.""" + + if settings.runtime_profile is not RuntimeProfile.PUBLIC: + return + if ( + dependencies.database_runtime is None + or dependencies.identity_repository is None + or dependencies.session_service is None + or dependencies.workspace_authorizer is None + ): + _public_error("public_database_dependencies_required") + if ( + getattr(dependencies, "credential_vault", None) is None + or not isinstance( + dependencies.connection_scoped_direct_client_factory, + PostgresConnectionScopedDirectClientFactory, + ) + ): + _public_error("public_connection_scoped_direct_factory_required") + if dependencies.fake_auth_enabled: + _public_error("public_fake_auth_enabled") + + from app.providers.yandex import PublicDirectClientFactory + + if not isinstance(dependencies.direct_client_factory, PublicDirectClientFactory): + _public_error("public_direct_client_factory_unsafe") + yandex_oauth = dependencies.yandex_oauth + if yandex_oauth is None: + _public_error("public_yandex_oauth_required") + if ( + yandex_oauth.config is None + or yandex_oauth.provider is None + or yandex_oauth.credential_persister is None + or yandex_oauth.transactions is None + or yandex_oauth.identities is None + ): + _public_error("public_yandex_oauth_dependencies_required") + if not _oauth_configuration_matches_settings(yandex_oauth.config, settings): + _public_error("public_yandex_oauth_configuration_mismatch") + if not isinstance(yandex_oauth.provider, HttpxYandexOAuthProvider): + _public_error("public_yandex_oauth_provider_invalid") + if not _oauth_configuration_matches_settings(yandex_oauth.provider.config, settings): + _public_error("public_yandex_oauth_configuration_mismatch") + lifecycle = dependencies.yandex_connection_lifecycle + if lifecycle is None: + _public_error("public_yandex_oauth_lifecycle_required") + if not isinstance(lifecycle, YandexConnectionLifecycleService): + _public_error("public_yandex_oauth_lifecycle_invalid") + if ( + lifecycle.provider is not yandex_oauth.provider + or lifecycle.repository is not yandex_oauth.credential_persister + ): + _public_error("public_yandex_oauth_graph_mismatch") diff --git a/app/config.py b/app/config.py index 84c5973..57546be 100644 --- a/app/config.py +++ b/app/config.py @@ -1,3 +1,4 @@ +from enum import Enum from functools import lru_cache from pydantic import AliasChoices, Field @@ -12,6 +13,11 @@ def mask_secret(value: str | None) -> str | None: return f"{value[:4]}…{value[-4:]}(len={len(value)})" +class RuntimeProfile(str, Enum): + OPERATOR_LOCAL = "operator_local" + PUBLIC = "public" + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", @@ -20,7 +26,24 @@ class Settings(BaseSettings): populate_by_name=True, ) - app_env: str = "local" + app_env: str = Field( + default="local", + validation_alias=AliasChoices("DIRECTPILOT_APP_ENV", "app_env"), + ) + runtime_profile: RuntimeProfile = Field( + default=RuntimeProfile.OPERATOR_LOCAL, + validation_alias=AliasChoices( + "DIRECTPILOT_RUNTIME_PROFILE", + "runtime_profile", + ), + ) + public_base_url: str | None = Field( + default=None, + validation_alias=AliasChoices( + "DIRECTPILOT_PUBLIC_BASE_URL", + "public_base_url", + ), + ) directpilot_mode: str = Field( default="live_readonly", pattern="^(mock|sandbox|live_readonly|live_write)$", diff --git a/app/main.py b/app/main.py index 5124bb0..4685536 100644 --- a/app/main.py +++ b/app/main.py @@ -1,23 +1,29 @@ -from app.api.legacy_handlers import _aggregate_search_query_tsv -from app.api.legacy_router import ( - get_settings, - get_yandex_client, - get_yandex_metrika_client, - get_yandex_search_wordstat_client, - router, - store, -) from app.bootstrap.application import create_app +from app.config import RuntimeProfile, Settings, get_settings + +settings = Settings() +app = create_app( + settings=settings, + include_legacy_router=settings.runtime_profile is RuntimeProfile.OPERATOR_LOCAL, +) -__all__ = [ - "app", - "_aggregate_search_query_tsv", - "get_settings", - "get_yandex_client", - "get_yandex_metrika_client", - "get_yandex_search_wordstat_client", - "store", -] +if settings.runtime_profile is RuntimeProfile.OPERATOR_LOCAL: + from app.api.legacy_handlers import _aggregate_search_query_tsv + from app.api.legacy_router import ( + get_yandex_client, + get_yandex_metrika_client, + get_yandex_search_wordstat_client, + store, + ) -app = create_app() -app.include_router(router) + __all__ = [ + "app", + "_aggregate_search_query_tsv", + "get_settings", + "get_yandex_client", + "get_yandex_metrika_client", + "get_yandex_search_wordstat_client", + "store", + ] +else: + __all__ = ["app"] diff --git a/app/modules/integrations/yandex/oauth.py b/app/modules/integrations/yandex/oauth.py index 04edfbf..f1068cc 100644 --- a/app/modules/integrations/yandex/oauth.py +++ b/app/modules/integrations/yandex/oauth.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Protocol -from urllib.parse import urlencode +from urllib.parse import urlencode, urlsplit from uuid import UUID from app.modules.integrations.yandex.credentials import ( @@ -17,12 +17,35 @@ YANDEX_AUTHORIZE_URL = "https://oauth.yandex.ru/authorize" LOCAL_YANDEX_CALLBACK_URI = "http://127.0.0.1:8000/api/v1/integrations/yandex/callback" +_PUBLIC_CALLBACK_PATH = "/api/v1/integrations/yandex/callback" MAX_OAUTH_TRANSACTION_TTL = timedelta(minutes=10) _ALLOWED_RETURN_PATHS = frozenset({"/"}) class OAuthConfigurationError(ValueError): - """Raised when the bounded local OAuth configuration is unsafe.""" + """Raised when the configured OAuth callback URI is unsafe.""" + + +def _is_supported_redirect_uri(redirect_uri: str) -> bool: + if redirect_uri == LOCAL_YANDEX_CALLBACK_URI: + return True + try: + parsed = urlsplit(redirect_uri) + port = parsed.port + except ValueError: + return False + return ( + parsed.scheme.lower() == "https" + and parsed.hostname is not None + and parsed.username is None + and parsed.password is None + and not parsed.query + and not parsed.fragment + and "?" not in redirect_uri + and "#" not in redirect_uri + and parsed.path == _PUBLIC_CALLBACK_PATH + and (port is None or port > 0) + ) @dataclass(frozen=True, slots=True) @@ -33,9 +56,14 @@ class YandexOAuthConfiguration: transaction_ttl: timedelta = MAX_OAUTH_TRANSACTION_TTL def __post_init__(self) -> None: - if not self.client_id or not self.client_secret: + if ( + not isinstance(self.client_id, str) + or not self.client_id.strip() + or not isinstance(self.client_secret, str) + or not self.client_secret.strip() + ): raise OAuthConfigurationError("Yandex OAuth is not configured") - if self.redirect_uri != LOCAL_YANDEX_CALLBACK_URI: + if not _is_supported_redirect_uri(self.redirect_uri): raise OAuthConfigurationError("Yandex OAuth redirect URI is not configured") if not timedelta() < self.transaction_ttl <= MAX_OAUTH_TRANSACTION_TTL: raise OAuthConfigurationError("Yandex OAuth transaction TTL is not configured") diff --git a/app/modules/integrations/yandex/provider.py b/app/modules/integrations/yandex/provider.py index 51fa8cb..abafd94 100644 --- a/app/modules/integrations/yandex/provider.py +++ b/app/modules/integrations/yandex/provider.py @@ -49,6 +49,10 @@ def __init__( self._transport = transport self._timeout_seconds = timeout_seconds + @property + def config(self) -> YandexOAuthConfiguration: + return self._config + def exchange_code(self, *, code: str, code_verifier: str) -> YandexOAuthTokenSet: try: with httpx.Client( diff --git a/app/modules/integrations/yandex/refresh.py b/app/modules/integrations/yandex/refresh.py index 003793c..7c1dd14 100644 --- a/app/modules/integrations/yandex/refresh.py +++ b/app/modules/integrations/yandex/refresh.py @@ -128,6 +128,14 @@ def __init__( self._provider = provider self._refresh_skew_seconds = refresh_skew_seconds + @property + def repository(self) -> YandexConnectionLifecycleRepository: + return self._repository + + @property + def provider(self) -> YandexRefreshProvider: + return self._provider + def refresh( self, *, diff --git a/app/modules/sessions/cookies.py b/app/modules/sessions/cookies.py index 1e5ad67..a816e4c 100644 --- a/app/modules/sessions/cookies.py +++ b/app/modules/sessions/cookies.py @@ -1,10 +1,31 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timezone +from typing import Literal from starlette.responses import Response -SESSION_COOKIE_NAME = "__Host-directpilot_session" + +@dataclass(frozen=True, slots=True) +class SessionCookiePolicy: + name: str + secure: bool + httponly: bool + samesite: Literal["lax", "strict", "none"] + path: str + domain: str | None + + +SESSION_COOKIE_POLICY = SessionCookiePolicy( + name="__Host-directpilot_session", + secure=True, + httponly=True, + samesite="lax", + path="/", + domain=None, +) +SESSION_COOKIE_NAME = SESSION_COOKIE_POLICY.name def set_session_cookie(response: Response, *, token: str, expires_at: datetime) -> None: @@ -13,23 +34,23 @@ def set_session_cookie(response: Response, *, token: str, expires_at: datetime) now = datetime.now(timezone.utc) max_age = max(0, int((expires_at - now).total_seconds())) response.set_cookie( - key=SESSION_COOKIE_NAME, + key=SESSION_COOKIE_POLICY.name, value=token, max_age=max_age, - path="/", - domain=None, - secure=True, - httponly=True, - samesite="lax", + path=SESSION_COOKIE_POLICY.path, + domain=SESSION_COOKIE_POLICY.domain, + secure=SESSION_COOKIE_POLICY.secure, + httponly=SESSION_COOKIE_POLICY.httponly, + samesite=SESSION_COOKIE_POLICY.samesite, ) def clear_session_cookie(response: Response) -> None: response.delete_cookie( - key=SESSION_COOKIE_NAME, - path="/", - domain=None, - secure=True, - httponly=True, - samesite="lax", + key=SESSION_COOKIE_POLICY.name, + path=SESSION_COOKIE_POLICY.path, + domain=SESSION_COOKIE_POLICY.domain, + secure=SESSION_COOKIE_POLICY.secure, + httponly=SESSION_COOKIE_POLICY.httponly, + samesite=SESSION_COOKIE_POLICY.samesite, ) diff --git a/app/providers/yandex.py b/app/providers/yandex.py index 033030f..485a906 100644 --- a/app/providers/yandex.py +++ b/app/providers/yandex.py @@ -13,6 +13,14 @@ def create(self, settings: Settings) -> YandexDirectClient | None: return YandexDirectClient(settings=settings) +class PublicDirectClientFactory: + """Public-safe generic factory that cannot select a global credential.""" + + def create(self, settings: Settings) -> YandexDirectClient | None: + del settings + return None + + class DefaultMetrikaClientFactory: def create(self, settings: Settings) -> YandexMetrikaClient: return YandexMetrikaClient(settings=settings) diff --git a/tests/test_p4_public_profile.py b/tests/test_p4_public_profile.py new file mode 100644 index 0000000..e280095 --- /dev/null +++ b/tests/test_p4_public_profile.py @@ -0,0 +1,1159 @@ +from __future__ import annotations + +import importlib +import logging +import sys +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from http.cookies import SimpleCookie +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError +from starlette.responses import Response + +from app.bootstrap import dependencies as dependencies_module +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import ( + ApplicationDependencies, + create_application_dependencies, +) +from app.bootstrap.public_profile import ( + PublicProfileConfigurationError, + validate_public_profile_dependencies, + validate_public_profile_static, +) +from app.config import RuntimeProfile, Settings, get_settings +from app.modules.integrations.yandex.client_factory import ( + PostgresConnectionScopedDirectClientFactory, +) +from app.modules.integrations.yandex.credentials import CredentialConfigurationError +from app.modules.integrations.yandex.oauth import ( + OAuthConfigurationError, + YandexOAuthConfiguration, + YandexOAuthIntegration, +) +from app.modules.integrations.yandex.provider import HttpxYandexOAuthProvider +from app.modules.integrations.yandex.refresh import YandexConnectionLifecycleService +from app.modules.sessions.cookies import ( + SESSION_COOKIE_POLICY, + SessionCookiePolicy, + clear_session_cookie, + set_session_cookie, +) +from app.providers.yandex import DefaultDirectClientFactory, PublicDirectClientFactory + + +def _valid_public_settings(**overrides: object) -> Settings: + values: dict[str, object] = { + "runtime_profile": RuntimeProfile.PUBLIC, + "public_base_url": "https://public.example.test", + "yandex_oauth_redirect_uri": "https://public.example.test/api/v1/integrations/yandex/callback", + "directpilot_mode": "live_readonly", + "yandex_client_id": "synthetic-client-id", + "yandex_client_secret": "synthetic-client-secret", + "yandex_oauth_token": None, + "yandex_metrika_oauth_token": None, + "yandex_search_api_key": None, + "credential_keyring_secret_file": "synthetic-keyring-path", + } + values.update(overrides) + return Settings(_env_file=None, **values) + + +def _assert_static_rejected( + settings: Settings, + *, + expected_code: str, + include_legacy_router: bool = False, + fake_auth_enabled: bool = False, + database_configured: bool = True, + cookie_policy: SessionCookiePolicy | None = None, +) -> None: + kwargs: dict[str, object] = { + "include_legacy_router": include_legacy_router, + "fake_auth_enabled": fake_auth_enabled, + "database_configured": database_configured, + } + if cookie_policy is not None: + kwargs["cookie_policy"] = cookie_policy + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_static(settings, **kwargs) + assert raised.value.code == expected_code + + +def test_valid_synthetic_public_settings_pass_static_validation() -> None: + validate_public_profile_static( + _valid_public_settings(), + include_legacy_router=False, + fake_auth_enabled=False, + database_configured=True, + ) + + +def test_session_cookie_policy_drives_both_set_and_clear_operations() -> None: + set_response = Response() + clear_response = Response() + set_session_cookie( + set_response, + token="synthetic-session-token", + expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), + ) + clear_session_cookie(clear_response) + + for response in (set_response, clear_response): + parsed = SimpleCookie() + parsed.load(response.headers["set-cookie"]) + morsel = parsed[SESSION_COOKIE_POLICY.name] + assert morsel["path"] == SESSION_COOKIE_POLICY.path + assert morsel["domain"] == "" + assert morsel["secure"] is True + assert morsel["httponly"] is True + assert morsel["samesite"] == SESSION_COOKIE_POLICY.samesite + + +@pytest.mark.parametrize( + ("overrides", "expected_code"), + ( + ({"directpilot_mode": "sandbox"}, "public_directpilot_mode_invalid"), + ({"yandex_client_id": None}, "public_oauth_client_id_required"), + ({"yandex_client_secret": None}, "public_oauth_client_secret_required"), + ({"credential_keyring_secret_file": None}, "public_credential_keyring_required"), + ({"yandex_oauth_token": "synthetic-global-direct-token"}, "public_global_yandex_oauth_token_configured"), + ({"yandex_metrika_oauth_token": "synthetic-global-metrika-token"}, "public_global_yandex_metrika_oauth_token_configured"), + ({"yandex_search_api_key": "synthetic-global-search-key"}, "public_global_yandex_search_api_key_configured"), + ), +) +def test_public_static_validation_rejects_each_non_url_setting_downgrade( + overrides: dict[str, object], + expected_code: str, +) -> None: + settings = _valid_public_settings(**overrides) + + _assert_static_rejected(settings, expected_code=expected_code) + + +def test_public_static_validation_rejects_legacy_router_request() -> None: + _assert_static_rejected( + _valid_public_settings(), + expected_code="public_legacy_router_enabled", + include_legacy_router=True, + ) + + +def test_public_static_validation_rejects_fake_auth() -> None: + _assert_static_rejected( + _valid_public_settings(), + expected_code="public_fake_auth_enabled", + fake_auth_enabled=True, + ) + + +def test_public_static_validation_rejects_missing_database_configuration() -> None: + _assert_static_rejected( + _valid_public_settings(), + expected_code="public_database_required", + database_configured=False, + ) + + +@pytest.mark.parametrize( + ("policy", "expected_code"), + ( + ( + SessionCookiePolicy( + name="directpilot_session", + secure=True, + httponly=True, + samesite="lax", + path="/", + domain=None, + ), + "public_cookie_policy_invalid", + ), + ( + SessionCookiePolicy( + name="__Host-directpilot_session", + secure=False, + httponly=True, + samesite="lax", + path="/", + domain=None, + ), + "public_cookie_policy_invalid", + ), + ( + SessionCookiePolicy( + name="__Host-directpilot_session", + secure=True, + httponly=False, + samesite="lax", + path="/", + domain=None, + ), + "public_cookie_policy_invalid", + ), + ( + SessionCookiePolicy( + name="__Host-directpilot_session", + secure=True, + httponly=True, + samesite="none", + path="/", + domain=None, + ), + "public_cookie_policy_invalid", + ), + ( + SessionCookiePolicy( + name="__Host-directpilot_session", + secure=True, + httponly=True, + samesite="lax", + path="/scope", + domain=None, + ), + "public_cookie_policy_invalid", + ), + ( + SessionCookiePolicy( + name="__Host-directpilot_session", + secure=True, + httponly=True, + samesite="lax", + path="/", + domain="public.example.test", + ), + "public_cookie_policy_invalid", + ), + ), +) +def test_public_static_validation_rejects_each_cookie_policy_downgrade( + policy: SessionCookiePolicy, + expected_code: str, +) -> None: + _assert_static_rejected( + _valid_public_settings(), + expected_code=expected_code, + cookie_policy=policy, + ) + + +@pytest.mark.parametrize( + ("public_base_url", "expected_code"), + ( + (None, "public_base_url_required"), + ("http://public.example.test", "public_base_url_invalid"), + ("/relative", "public_base_url_invalid"), + ("//public.example.test", "public_base_url_invalid"), + ("https://user:pass@public.example.test", "public_base_url_invalid"), + ("https://public.example.test?query=1", "public_base_url_invalid"), + ("https://public.example.test#fragment", "public_base_url_invalid"), + ("https:///missing-host", "public_base_url_invalid"), + ), +) +def test_public_static_validation_rejects_invalid_public_base_url( + public_base_url: str | None, + expected_code: str, +) -> None: + _assert_static_rejected( + _valid_public_settings(public_base_url=public_base_url), + expected_code=expected_code, + ) + + +@pytest.mark.parametrize( + ("callback_url", "expected_code"), + ( + ("http://public.example.test/api/v1/integrations/yandex/callback", "public_oauth_redirect_uri_invalid"), + ("https://other.example.test/api/v1/integrations/yandex/callback", "public_oauth_callback_origin_mismatch"), + ("https://public.example.test/wrong", "public_oauth_callback_path_invalid"), + ("https://public.example.test/api/v1/integrations/yandex/callback?query=1", "public_oauth_redirect_uri_invalid"), + ("https://public.example.test/api/v1/integrations/yandex/callback#fragment", "public_oauth_redirect_uri_invalid"), + ), +) +def test_public_static_validation_rejects_invalid_oauth_callback( + callback_url: str, + expected_code: str, +) -> None: + _assert_static_rejected( + _valid_public_settings(yandex_oauth_redirect_uri=callback_url), + expected_code=expected_code, + ) + + +def test_settings_profile_aliases_are_explicit_and_default_to_operator_local( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "public") + monkeypatch.setenv("DIRECTPILOT_PUBLIC_BASE_URL", "https://public.example.test") + + settings = Settings(_env_file=None) + + assert settings.runtime_profile is RuntimeProfile.PUBLIC + assert settings.public_base_url == "https://public.example.test" + assert Settings(_env_file=None, runtime_profile=RuntimeProfile.OPERATOR_LOCAL).runtime_profile is RuntimeProfile.OPERATOR_LOCAL + + +def test_settings_rejects_an_unknown_runtime_profile() -> None: + with pytest.raises(ValidationError): + Settings(_env_file=None, runtime_profile="unrecognized") + + +def test_public_configuration_error_never_echoes_a_global_credential() -> None: + marker = "synthetic-secret-value-that-must-not-appear" + settings = _valid_public_settings(yandex_oauth_token=marker) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_static( + settings, + include_legacy_router=False, + fake_auth_enabled=False, + database_configured=True, + ) + + assert marker not in str(raised.value) + assert marker not in raised.value.message + + +class _CloseCountingRuntime: + sessions = object() + + def __init__(self) -> None: + self.close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + + +def _valid_public_dependencies() -> ApplicationDependencies: + settings = _valid_public_settings() + assert settings.yandex_client_id is not None + assert settings.yandex_client_secret is not None + oauth_config = YandexOAuthConfiguration( + client_id=settings.yandex_client_id, + client_secret=settings.yandex_client_secret, + redirect_uri=settings.yandex_oauth_redirect_uri, + ) + connection_repository = object() + provider = HttpxYandexOAuthProvider(config=oauth_config) + return ApplicationDependencies( + repository=object(), + direct_client_factory=PublicDirectClientFactory(), + metrika_client_factory=object(), + wordstat_client_factory=object(), + connection_scoped_direct_client_factory=PostgresConnectionScopedDirectClientFactory( + sessions=object(), + vault=object(), + workspace_authorizer=object(), + settings=settings, + ), + database_runtime=_CloseCountingRuntime(), + identity_repository=object(), + session_service=object(), + workspace_authorizer=object(), + credential_vault=object(), + yandex_oauth=YandexOAuthIntegration( + transactions=object(), + identities=object(), + config=oauth_config, + provider=provider, + credential_persister=connection_repository, + ), + yandex_connection_lifecycle=YandexConnectionLifecycleService( + repository=connection_repository, + provider=provider, + ), + ) + + +def test_valid_synthetic_public_dependencies_pass_post_validation() -> None: + validate_public_profile_dependencies(_valid_public_settings(), _valid_public_dependencies()) + + +def test_public_application_lifespan_closes_database_runtime_once() -> None: + runtime = _CloseCountingRuntime() + dependencies = replace(_valid_public_dependencies(), database_runtime=runtime) + app = create_app(settings=_valid_public_settings(), dependencies=dependencies) + + with TestClient(app, base_url="https://public.example.test") as client: + assert client.get("/health").status_code == 200 + + assert runtime.close_calls == 1 + + +def test_public_static_rejection_closes_explicit_database_runtime_once() -> None: + runtime = _CloseCountingRuntime() + dependencies = replace(_valid_public_dependencies(), database_runtime=runtime) + + with pytest.raises(PublicProfileConfigurationError): + create_app( + settings=_valid_public_settings(), + dependencies=dependencies, + include_legacy_router=True, + ) + + assert runtime.close_calls == 1 + + +def test_public_dependency_rejection_closes_explicit_database_runtime_once() -> None: + runtime = _CloseCountingRuntime() + dependencies = replace( + _valid_public_dependencies(), + database_runtime=runtime, + yandex_oauth=None, + ) + + with pytest.raises(PublicProfileConfigurationError): + create_app(settings=_valid_public_settings(), dependencies=dependencies) + + assert runtime.close_calls == 1 + + +def test_app_construction_failure_closes_database_runtime_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = _CloseCountingRuntime() + dependencies = replace(_valid_public_dependencies(), database_runtime=runtime) + + def fail_fastapi(**_kwargs: object) -> None: + raise RuntimeError("synthetic-app-construction-failure") + + monkeypatch.setattr("app.bootstrap.application.FastAPI", fail_fastapi) + + with pytest.raises(RuntimeError): + create_app(settings=_valid_public_settings(), dependencies=dependencies) + + assert runtime.close_calls == 1 + + +def test_public_post_validation_rejects_missing_yandex_oauth_integration() -> None: + dependencies = replace(_valid_public_dependencies(), yandex_oauth=None) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_required" + + +@pytest.mark.parametrize( + "changes", + ( + {"config": None}, + {"provider": None}, + {"credential_persister": None}, + {"transactions": None}, + {"identities": None}, + ), +) +def test_public_post_validation_rejects_each_missing_yandex_oauth_seam( + changes: dict[str, object], +) -> None: + dependencies = _valid_public_dependencies() + integration = dependencies.yandex_oauth + assert integration is not None + dependencies = replace(dependencies, yandex_oauth=replace(integration, **changes)) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_dependencies_required" + + +def test_public_post_validation_rejects_missing_yandex_lifecycle() -> None: + dependencies = replace(_valid_public_dependencies(), yandex_connection_lifecycle=None) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_lifecycle_required" + + +@pytest.mark.parametrize("field", ("client_id", "client_secret", "redirect_uri")) +def test_public_post_validation_rejects_yandex_oauth_configuration_mismatch( + field: str, +) -> None: + dependencies = _valid_public_dependencies() + integration = dependencies.yandex_oauth + assert integration is not None + config = integration.config + assert config is not None + marker = f"synthetic-mismatch-{field}" + mismatched_config = YandexOAuthConfiguration( + client_id=marker if field == "client_id" else config.client_id, + client_secret=marker if field == "client_secret" else config.client_secret, + redirect_uri=( + "https://other.example.test/api/v1/integrations/yandex/callback" + if field == "redirect_uri" + else config.redirect_uri + ), + ) + dependencies = replace( + dependencies, + yandex_oauth=replace(integration, config=mismatched_config), + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_configuration_mismatch" + assert marker not in str(raised.value) + + +def test_public_post_validation_rejects_provider_configuration_mismatch() -> None: + dependencies = _valid_public_dependencies() + integration = dependencies.yandex_oauth + assert integration is not None + config = integration.config + assert config is not None + marker = "synthetic-provider-mismatch" + provider = HttpxYandexOAuthProvider( + config=YandexOAuthConfiguration( + client_id=config.client_id, + client_secret=config.client_secret, + redirect_uri="https://other.example.test/api/v1/integrations/yandex/callback", + ) + ) + dependencies = replace( + dependencies, + yandex_oauth=replace(integration, provider=provider), + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_configuration_mismatch" + assert marker not in str(raised.value) + + +def test_public_post_validation_rejects_non_concrete_yandex_oauth_provider() -> None: + dependencies = _valid_public_dependencies() + integration = dependencies.yandex_oauth + assert integration is not None + dependencies = replace( + dependencies, + yandex_oauth=replace(integration, provider=object()), + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_provider_invalid" + + +@pytest.mark.parametrize("mismatch", ("provider", "credential_persister")) +def test_public_post_validation_rejects_yandex_lifecycle_graph_mismatch( + mismatch: str, +) -> None: + dependencies = _valid_public_dependencies() + integration = dependencies.yandex_oauth + assert integration is not None + config = integration.config + assert config is not None + provider = ( + HttpxYandexOAuthProvider(config=config) + if mismatch == "provider" + else integration.provider + ) + credential_persister = ( + object() if mismatch == "credential_persister" else integration.credential_persister + ) + assert provider is not None + assert credential_persister is not None + dependencies = replace( + dependencies, + yandex_connection_lifecycle=YandexConnectionLifecycleService( + repository=credential_persister, + provider=provider, + ), + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_graph_mismatch" + + +def test_public_post_validation_rejects_non_concrete_yandex_lifecycle() -> None: + dependencies = replace(_valid_public_dependencies(), yandex_connection_lifecycle=object()) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == "public_yandex_oauth_lifecycle_invalid" + + +@pytest.mark.parametrize( + ("changes", "expected_code"), + ( + ({"database_runtime": None}, "public_database_dependencies_required"), + ({"identity_repository": None}, "public_database_dependencies_required"), + ({"session_service": None}, "public_database_dependencies_required"), + ({"workspace_authorizer": None}, "public_database_dependencies_required"), + ({"credential_vault": None}, "public_connection_scoped_direct_factory_required"), + ({"connection_scoped_direct_client_factory": None}, "public_connection_scoped_direct_factory_required"), + ({"connection_scoped_direct_client_factory": object()}, "public_connection_scoped_direct_factory_required"), + ({"fake_auth_enabled": True}, "public_fake_auth_enabled"), + ({"direct_client_factory": DefaultDirectClientFactory()}, "public_direct_client_factory_unsafe"), + ), +) +def test_public_post_validation_rejects_each_dependency_downgrade( + changes: dict[str, object], + expected_code: str, +) -> None: + dependencies = replace(_valid_public_dependencies(), **changes) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_dependencies(_valid_public_settings(), dependencies) + + assert raised.value.code == expected_code + + +def test_public_generic_direct_factory_never_creates_a_global_token_client() -> None: + factory = PublicDirectClientFactory() + + assert factory.create( + _valid_public_settings(yandex_oauth_token="synthetic-global-direct-token") + ) is None + + +def test_create_application_dependencies_uses_the_explicit_settings_object( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings(_env_file=None, runtime_profile=RuntimeProfile.OPERATOR_LOCAL) + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + monkeypatch.setattr( + dependencies_module, + "get_settings", + lambda: pytest.fail("explicit Settings must be used"), + ) + + dependencies = create_application_dependencies(settings) + + assert isinstance(dependencies.direct_client_factory, DefaultDirectClientFactory) + + +def test_no_settings_bootstrap_resolves_current_production_profile_after_cache_warm( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + monkeypatch.delenv("DIRECTPILOT_ENABLE_FAKE_AUTH", raising=False) + for name in ( + "YANDEX_OAUTH_TOKEN", + "YANDEX_METRIKA_OAUTH_TOKEN", + "YANDEX_SEARCH_API_KEY", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "local") + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "operator_local") + get_settings.cache_clear() + try: + cached_settings = get_settings() + assert cached_settings.app_env == "local" + assert cached_settings.runtime_profile is RuntimeProfile.OPERATOR_LOCAL + + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "production") + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "public") + monkeypatch.setenv("DIRECTPILOT_PUBLIC_BASE_URL", "https://public.example.test") + monkeypatch.setenv( + "YANDEX_OAUTH_REDIRECT_URI", + "https://public.example.test/api/v1/integrations/yandex/callback", + ) + monkeypatch.setenv("YANDEX_CLIENT_ID", "synthetic-client-id") + monkeypatch.setenv("YANDEX_CLIENT_SECRET", "synthetic-client-secret") + monkeypatch.setenv("DIRECTPILOT_CREDENTIAL_KEYRING_SECRET_FILE", "synthetic-keyring-path") + + with pytest.raises(PublicProfileConfigurationError) as raised: + create_application_dependencies() + + assert raised.value.code == "public_database_required" + finally: + get_settings.cache_clear() + + +def test_public_missing_database_fails_before_database_runtime_creation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = _valid_public_settings() + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + monkeypatch.setattr( + dependencies_module, + "create_database_runtime", + lambda _settings: pytest.fail("database runtime must not be created"), + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + create_application_dependencies(settings) + + assert raised.value.code == "public_database_required" + + +def test_public_post_validation_failure_closes_created_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Runtime: + sessions = object() + + def __init__(self) -> None: + self.close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + + runtime = Runtime() + settings = _valid_public_settings() + monkeypatch.setenv( + dependencies_module.DATABASE_URL_ENV, + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot", + ) + monkeypatch.setattr( + dependencies_module, + "create_database_runtime", + lambda _settings: runtime, + ) + monkeypatch.setattr(dependencies_module, "check_schema_compatibility", lambda _runtime: None) + monkeypatch.setattr(dependencies_module, "_configured_credential_vault", lambda _settings: object()) + + def reject_post_validation(_settings: Settings, _dependencies: ApplicationDependencies) -> None: + raise PublicProfileConfigurationError("public_direct_client_factory_unsafe") + + monkeypatch.setattr( + dependencies_module, + "validate_public_profile_dependencies", + reject_post_validation, + ) + + with pytest.raises(PublicProfileConfigurationError): + create_application_dependencies(settings) + + assert runtime.close_calls == 1 + + +def test_public_unavailable_keyring_is_safe_and_prevents_database_creation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + marker = "synthetic-keyring-error-that-must-not-escape" + monkeypatch.setenv( + dependencies_module.DATABASE_URL_ENV, + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot", + ) + monkeypatch.setattr( + dependencies_module, + "_configured_credential_vault", + lambda _settings: (_ for _ in ()).throw(CredentialConfigurationError(marker)), + ) + monkeypatch.setattr( + dependencies_module, + "create_database_runtime", + lambda _settings: pytest.fail("database runtime must not be created"), + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + create_application_dependencies(_valid_public_settings()) + + assert raised.value.code == "public_credential_keyring_unavailable" + assert marker not in str(raised.value) + + +def test_valid_synthetic_public_runtime_dependencies_are_created( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Runtime: + sessions = object() + + def __init__(self) -> None: + self.close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + + runtime = Runtime() + vault = object() + monkeypatch.setenv( + dependencies_module.DATABASE_URL_ENV, + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot", + ) + monkeypatch.setattr( + dependencies_module, + "create_database_runtime", + lambda _settings: runtime, + ) + monkeypatch.setattr(dependencies_module, "check_schema_compatibility", lambda _runtime: None) + monkeypatch.setattr(dependencies_module, "_configured_credential_vault", lambda _settings: vault) + + dependencies = create_application_dependencies(_valid_public_settings()) + + assert isinstance(dependencies.direct_client_factory, PublicDirectClientFactory) + assert isinstance( + dependencies.connection_scoped_direct_client_factory, + PostgresConnectionScopedDirectClientFactory, + ) + assert dependencies.credential_vault is vault + assert dependencies.fake_auth_enabled is False + assert dependencies.database_runtime is runtime + + app = create_app(settings=_valid_public_settings(), dependencies=dependencies) + + with TestClient(app, base_url="https://public.example.test") as client: + assert client.get("/health").status_code == 200 + + assert runtime.close_calls == 1 + + +def test_public_app_exposes_only_platform_health_and_oauth_routes() -> None: + app = create_app( + settings=_valid_public_settings(), + dependencies=_valid_public_dependencies(), + ) + paths = {route.path for route in app.routes} + client = TestClient(app, base_url="https://public.example.test") + + assert {"/health", "/ready", "/api/v1/integrations/yandex/callback"} <= paths + assert "/campaigns" not in paths + assert "/api/v1/_test/identity/login" not in paths + assert app.docs_url is None + assert app.redoc_url is None + assert app.openapi_url is None + assert client.get("/health").json() == {"status": "ok"} + assert client.get("/ready").json() == {"status": "ready"} + assert client.get("/api/v1/integrations/yandex/start").status_code == 401 + assert client.post("/campaign-drafts", json={}).status_code == 404 + assert client.post("/api/v1/_test/identity/login", json={}).status_code == 404 + for path in ( + "/campaigns", + "/api/v1/_test/identity/login", + "/docs", + "/redoc", + "/openapi.json", + ): + assert client.get(path).status_code == 404 + + +def test_public_static_validation_rejects_explicit_https_port_zero() -> None: + settings = _valid_public_settings( + public_base_url="https://public.example.test:0", + yandex_oauth_redirect_uri=( + "https://public.example.test:0/api/v1/integrations/yandex/callback" + ), + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_static( + settings, + include_legacy_router=False, + fake_auth_enabled=False, + database_configured=True, + ) + + assert raised.value.code == "public_base_url_invalid" + + +@pytest.mark.parametrize( + ("field", "expected_code"), + ( + ("yandex_client_id", "public_oauth_client_id_required"), + ("yandex_client_secret", "public_oauth_client_secret_required"), + ), +) +def test_public_static_validation_rejects_whitespace_oauth_application_values( + field: str, + expected_code: str, +) -> None: + _assert_static_rejected( + _valid_public_settings(**{field: " "}), + expected_code=expected_code, + ) + + +@pytest.mark.parametrize("field", ("client_id", "client_secret", "redirect_uri")) +def test_yandex_oauth_configuration_rejects_unusable_public_values(field: str) -> None: + marker = "synthetic-oauth-configuration-marker" + with pytest.raises(OAuthConfigurationError) as raised: + YandexOAuthConfiguration( + client_id=" " if field == "client_id" else marker, + client_secret=" " if field == "client_secret" else marker, + redirect_uri=( + "https://public.example.test:0/api/v1/integrations/yandex/callback" + if field == "redirect_uri" + else "https://public.example.test/api/v1/integrations/yandex/callback" + ), + ) + + assert marker not in str(raised.value) + + +def test_create_app_uses_fresh_public_settings_after_operator_cache_warm( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + monkeypatch.delenv("DIRECTPILOT_ENABLE_FAKE_AUTH", raising=False) + for name in ( + "YANDEX_OAUTH_TOKEN", + "YANDEX_METRIKA_OAUTH_TOKEN", + "YANDEX_SEARCH_API_KEY", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "local") + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "operator_local") + get_settings.cache_clear() + try: + assert get_settings().runtime_profile is RuntimeProfile.OPERATOR_LOCAL + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "production") + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "public") + monkeypatch.setenv("DIRECTPILOT_PUBLIC_BASE_URL", "https://public.example.test") + monkeypatch.setenv( + "YANDEX_OAUTH_REDIRECT_URI", + "https://public.example.test/api/v1/integrations/yandex/callback", + ) + monkeypatch.setenv("YANDEX_CLIENT_ID", "synthetic-client-id") + monkeypatch.setenv("YANDEX_CLIENT_SECRET", "synthetic-client-secret") + monkeypatch.setenv("DIRECTPILOT_CREDENTIAL_KEYRING_SECRET_FILE", "synthetic-keyring-path") + + app = create_app(dependencies=_valid_public_dependencies()) + + assert app.docs_url is None + assert app.redoc_url is None + assert app.openapi_url is None + paths = {route.path for route in app.routes} + assert "/campaigns" not in paths + assert "/api/v1/_test/identity/login" not in paths + finally: + get_settings.cache_clear() + + +def test_main_uses_fresh_public_settings_after_operator_cache_warm( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + monkeypatch.delenv("DIRECTPILOT_ENABLE_FAKE_AUTH", raising=False) + for name in ( + "YANDEX_OAUTH_TOKEN", + "YANDEX_METRIKA_OAUTH_TOKEN", + "YANDEX_SEARCH_API_KEY", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "local") + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "operator_local") + app_package = importlib.import_module("app") + package_main_was_bound = "main" in app_package.__dict__ + previous_package_main = app_package.__dict__.get("main") + previous_main = sys.modules.pop("app.main", None) + get_settings.cache_clear() + monkeypatch.setattr( + "app.bootstrap.application.create_application_dependencies", + lambda _settings, *, include_legacy_router: _valid_public_dependencies(), + ) + try: + assert get_settings().runtime_profile is RuntimeProfile.OPERATOR_LOCAL + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "production") + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "public") + monkeypatch.setenv("DIRECTPILOT_PUBLIC_BASE_URL", "https://public.example.test") + monkeypatch.setenv( + "YANDEX_OAUTH_REDIRECT_URI", + "https://public.example.test/api/v1/integrations/yandex/callback", + ) + monkeypatch.setenv("YANDEX_CLIENT_ID", "synthetic-client-id") + monkeypatch.setenv("YANDEX_CLIENT_SECRET", "synthetic-client-secret") + monkeypatch.setenv("DIRECTPILOT_CREDENTIAL_KEYRING_SECRET_FILE", "synthetic-keyring-path") + + app = importlib.import_module("app.main").app + + assert app.docs_url is None + assert app.redoc_url is None + assert app.openapi_url is None + paths = {route.path for route in app.routes} + assert "/campaigns" not in paths + assert "/api/v1/_test/identity/login" not in paths + finally: + sys.modules.pop("app.main", None) + if previous_main is not None: + sys.modules["app.main"] = previous_main + if package_main_was_bound: + app_package.__dict__["main"] = previous_package_main + else: + app_package.__dict__.pop("main", None) + get_settings.cache_clear() + + +def test_public_health_does_not_expose_configuration_or_credentials() -> None: + marker = "synthetic-secret-value-that-must-not-appear" + app = create_app( + settings=_valid_public_settings(), + dependencies=_valid_public_dependencies(), + ) + + response = TestClient(app, base_url="https://public.example.test").get("/health") + + assert response.status_code == 200 + assert marker not in response.text + assert "token" not in response.text.lower() + assert "credential" not in response.text.lower() + + +def test_public_app_rejects_legacy_router_before_returning_an_asgi_application() -> None: + with pytest.raises(PublicProfileConfigurationError) as raised: + create_app( + settings=_valid_public_settings(), + dependencies=_valid_public_dependencies(), + include_legacy_router=True, + ) + + assert raised.value.code == "public_legacy_router_enabled" + + +def test_operator_application_factory_keeps_existing_identity_and_docs_wiring() -> None: + settings = Settings(_env_file=None, runtime_profile=RuntimeProfile.OPERATOR_LOCAL) + dependencies = create_application_dependencies(settings) + + app = create_app(settings=settings, dependencies=dependencies) + paths = {route.path for route in app.routes} + + assert "/api/v1/_test/identity/login" in paths + assert "/api/v1/integrations/yandex/callback" in paths + assert app.docs_url == "/docs" + assert app.redoc_url == "/redoc" + assert app.openapi_url == "/openapi.json" + + +def test_public_fake_auth_environment_is_rejected_before_database_creation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DIRECTPILOT_ENABLE_FAKE_AUTH", "1") + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + + with pytest.raises(PublicProfileConfigurationError) as raised: + create_application_dependencies(_valid_public_settings()) + + assert raised.value.code == "public_fake_auth_enabled" + + +def test_public_fake_auth_environment_is_rejected_even_when_app_env_is_production( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DIRECTPILOT_ENABLE_FAKE_AUTH", "1") + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + + with pytest.raises(PublicProfileConfigurationError) as raised: + create_application_dependencies(_valid_public_settings(app_env="production")) + + assert raised.value.code == "public_fake_auth_enabled" + + +def test_public_global_oauth_environment_credential_is_rejected_without_echoing_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + marker = "synthetic-global-token-from-environment" + monkeypatch.setenv("YANDEX_OAUTH_TOKEN", marker) + settings = Settings( + _env_file=None, + runtime_profile=RuntimeProfile.PUBLIC, + public_base_url="https://public.example.test", + yandex_oauth_redirect_uri="https://public.example.test/api/v1/integrations/yandex/callback", + directpilot_mode="live_readonly", + yandex_client_id="synthetic-client-id", + yandex_client_secret="synthetic-client-secret", + credential_keyring_secret_file="synthetic-keyring-path", + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + validate_public_profile_static( + settings, + include_legacy_router=False, + fake_auth_enabled=False, + database_configured=True, + ) + + assert raised.value.code == "public_global_yandex_oauth_token_configured" + assert marker not in str(raised.value) + + +def test_public_startup_rejection_does_not_log_a_configured_credential( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + marker = "synthetic-secret-value-that-must-not-appear-in-logs" + caplog.set_level(logging.DEBUG) + monkeypatch.setenv( + dependencies_module.DATABASE_URL_ENV, + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot", + ) + + with pytest.raises(PublicProfileConfigurationError) as raised: + create_application_dependencies(_valid_public_settings(yandex_oauth_token=marker)) + + assert raised.value.code == "public_global_yandex_oauth_token_configured" + assert marker not in str(raised.value) + assert marker not in caplog.text + + +def test_public_main_entrypoint_never_imports_legacy_or_identity_test_router( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import app.bootstrap.application as application_module + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv(dependencies_module.DATABASE_URL_ENV, raising=False) + monkeypatch.delenv("DIRECTPILOT_ENABLE_FAKE_AUTH", raising=False) + for name in ( + "YANDEX_OAUTH_TOKEN", + "YANDEX_METRIKA_OAUTH_TOKEN", + "YANDEX_SEARCH_API_KEY", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "production") + monkeypatch.setenv("DIRECTPILOT_RUNTIME_PROFILE", "public") + monkeypatch.setenv("DIRECTPILOT_PUBLIC_BASE_URL", "https://public.example.test") + monkeypatch.setenv( + "YANDEX_OAUTH_REDIRECT_URI", + "https://public.example.test/api/v1/integrations/yandex/callback", + ) + monkeypatch.setenv("YANDEX_CLIENT_ID", "synthetic-client-id") + monkeypatch.setenv("YANDEX_CLIENT_SECRET", "synthetic-client-secret") + monkeypatch.setenv("DIRECTPILOT_CREDENTIAL_KEYRING_SECRET_FILE", "synthetic-keyring-path") + dependencies = _valid_public_dependencies() + monkeypatch.setattr( + application_module, + "create_application_dependencies", + lambda configured_settings, *, include_legacy_router: dependencies, + ) + app_package = importlib.import_module("app") + original_main = sys.modules.get("app.main") + package_main_was_bound = "main" in app_package.__dict__ + original_package_main = app_package.__dict__.get("main") + monkeypatch.delitem(sys.modules, "app.main", raising=False) + monkeypatch.delitem(sys.modules, "app.api.legacy_router", raising=False) + monkeypatch.delitem(sys.modules, "app.modules.identity.router", raising=False) + + try: + main = importlib.import_module("app.main") + + assert main.app.state.dependencies is dependencies + assert main.__all__ == ["app"] + assert "app.api.legacy_router" not in sys.modules + assert "app.modules.identity.router" not in sys.modules + finally: + if original_main is None: + sys.modules.pop("app.main", None) + else: + sys.modules["app.main"] = original_main + if package_main_was_bound: + app_package.__dict__["main"] = original_package_main + else: + app_package.__dict__.pop("main", None) + + if original_main is None: + assert "app.main" not in sys.modules + else: + assert sys.modules["app.main"] is original_main + if package_main_was_bound: + assert app_package.__dict__["main"] is original_package_main + else: + assert "main" not in app_package.__dict__