diff --git a/pyproject.toml b/pyproject.toml index 7a5b88b..23c52df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ dependencies = [ "python-multipart>=0.0.27", "jinja2>=3.1", "py-key-value-aio[disk]", + "aiosqlite>=0.20", + "asyncpg>=0.30", "pyjwt>=2.12.1", "argon2-cffi>=25.1.0", "base58>=2.1.1", @@ -51,6 +53,7 @@ dev = [ "ruff>=0.9", "ty", "httpx>=0.28.1", + "pre-commit>=4.6.0", ] [project.scripts] diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index bb384dd..45764be 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -13,23 +13,18 @@ from authsome.auth.sessions import AuthSessionStore from authsome.errors import AuthsomeError from authsome.identity.proof import ReplayCache -from authsome.paths import get_server_log_path from authsome.server.analytics import init_posthog, shutdown_posthog from authsome.server.dependencies import ( - create_app_store, create_hosted_account_service, create_identity_bootstrap_service, - create_identity_claim_registry, create_ownership_resolver, - create_principal_vault_binding_registry, + create_store, create_vault, - create_vault_registry, - get_identity_registry_path, get_server_base_url, + get_server_log_path, load_server_config, load_ui_session_signing_secret, ) -from authsome.server.registries import IdentityRegistrationError, IdentityRegistry from authsome.server.routes.auth import router as auth_router from authsome.server.routes.connections import router as connections_router from authsome.server.routes.health import router as health_router @@ -38,33 +33,35 @@ from authsome.server.routes.proxy import router as proxy_router from authsome.server.routes.ui import UiAuthRequiredError from authsome.server.routes.ui import router as ui_router +from authsome.server.store.repositories import IdentityRegistrationError from authsome.server.ui_sessions import UiSessionStore @asynccontextmanager async def lifespan(app: FastAPI): """Manage daemon lifecycle.""" - app.state.store = await create_app_store() - app.state.server_config = load_server_config(app.state.store.home) + app.state.store = await create_store() + app.state.server_config = await load_server_config(app.state.store) audit.setup(get_server_log_path(app.state.store.home)) - app.state.vault = await create_vault(app.state.store) + app.state.vault = await create_vault(app.state.store.home) app.state.auth_sessions = AuthSessionStore() app.state.ui_sessions = UiSessionStore(load_ui_session_signing_secret(app.state.store.home)) app.state.proof_replay_cache = ReplayCache() - app.state.identity_registry = IdentityRegistry(get_identity_registry_path(app.state.store.home)) - app.state.vault_registry = create_vault_registry(app.state.store.home) - app.state.identity_claim_registry = create_identity_claim_registry(app.state.store.home) - app.state.principal_vault_binding_registry = create_principal_vault_binding_registry(app.state.store.home) - app.state.hosted_account_service = create_hosted_account_service(app.state.store.home) + app.state.identity_registry = app.state.store.identity_registry + app.state.vault_registry = app.state.store.vaults + app.state.identity_claim_registry = app.state.store.identity_claims + app.state.principal_vault_binding_registry = app.state.store.principal_vault_bindings + app.state.provider_definition_repository = app.state.store.provider_definitions + app.state.hosted_account_service = create_hosted_account_service(app.state.store) app.state.server_base_url = get_server_base_url() init_posthog() app.state.identity_bootstrap = create_identity_bootstrap_service( app.state.identity_registry, app.state.ui_sessions, - home=app.state.store.home, + store=app.state.store, server_base_url=app.state.server_base_url, ) - app.state.ownership_resolver = create_ownership_resolver(app.state.store.home) + app.state.ownership_resolver = create_ownership_resolver(app.state.store) app.state.ownership_cache = {} yield shutdown_posthog() diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 5844681..1cd1c45 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -40,12 +40,12 @@ IdentityNotFoundError, InvalidProviderSchemaError, OperationNotAllowedError, - ProviderAlreadyRegisteredError, ProviderNotFoundError, RefreshFailedError, TokenExpiredError, UnsupportedFlowError, ) +from authsome.server.store.repositories import ProviderDefinitionRepository from authsome.utils import build_store_key, format_duration, is_filesystem_safe, parse_store_key, utc_now from authsome.vault import Vault @@ -86,6 +86,7 @@ class AuthService: def __init__( self, vault: Vault, + provider_definitions: ProviderDefinitionRepository, identity: str | None = None, principal_id: str | None = None, vault_id: str | None = None, @@ -96,6 +97,7 @@ def __init__( self._principal_id = principal_id self._vault_id = vault_id self._deployment_mode = "hosted" if deployment_mode == "hosted" else "local" + self._provider_definitions = provider_definitions self._bundled: dict[str, ProviderDefinition] = self._load_bundled_providers() @property @@ -150,15 +152,8 @@ def _load_bundled_providers() -> dict[str, ProviderDefinition]: return bundled async def _load_custom_providers(self) -> dict[str, ProviderDefinition]: - providers: dict[str, ProviderDefinition] = {} - try: - for name in await self._vault.list(collection="providers"): - raw = await self._vault.get(name, collection="providers") - if raw: - providers[name] = ProviderDefinition.model_validate_json(raw) - except Exception as exc: - logger.warning("Could not load custom providers: {}", exc) - return providers + providers = await self._provider_definitions.list() + return {provider.name: provider for provider in providers} async def list_providers(self) -> list[ProviderDefinition]: providers = {**self._bundled, **(await self._load_custom_providers())} @@ -171,17 +166,16 @@ async def list_providers_by_source(self) -> dict[str, list[ProviderDefinition]]: return {"bundled": bundled_list, "custom": custom_list} async def get_provider(self, provider: str) -> ProviderDefinition: - raw = await self._vault.get(provider, collection="providers") - if raw: - return ProviderDefinition.model_validate_json(raw) + custom = await self._provider_definitions.get(provider) + if custom is not None: + return custom if provider in self._bundled: return self._bundled[provider] raise ProviderNotFoundError(provider) async def is_local_provider(self, provider: str) -> bool: """Check if a provider is a custom/local provider.""" - val = await self._vault.get(provider, collection="providers") - return val is not None + return await self._provider_definitions.get(provider) is not None async def resolve_credentials(self, **kwargs: Any) -> dict[str, Any]: """Resolve credentials for a provider/connection pair.""" @@ -200,20 +194,12 @@ async def resolve_credentials(self, **kwargs: Any) -> dict[str, Any]: async def register_provider(self, definition: ProviderDefinition, *, force: bool = False) -> None: self._ensure_local_provider_admin_operation_allowed("register", definition.name) self._validate_provider(definition) - has_custom = (await self._vault.get(definition.name, collection="providers")) is not None - if force or not has_custom: - await self._vault.put( - definition.name, - definition.model_dump_json(indent=2, exclude_none=True), - collection="providers", - ) - else: - raise ProviderAlreadyRegisteredError(definition.name) + await self._provider_definitions.save(definition, force=force) logger.info("Registered provider: {}", definition.name) async def remove_provider(self, name: str) -> bool: """Remove a custom provider. Returns True if removed.""" - return await self._vault.delete(name, collection="providers") + return await self._provider_definitions.delete(name) def _ensure_local_provider_admin_operation_allowed(self, operation: str, provider: str) -> None: if is_admin_principal(self._principal_id): @@ -777,6 +763,7 @@ async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> Non principal_id=self._principal_id, vault_id=vault_id, deployment_mode=self._deployment_mode, + provider_definitions=self._provider_definitions, ) meta_key = build_store_key(vault=vault_id, provider=provider, record_type="metadata") existing_json = await self._vault.get(meta_key, collection=vault_service._coll) @@ -796,7 +783,7 @@ async def remove(self, provider: str) -> None: self._ensure_local_provider_admin_operation_allowed("remove", provider) await self.revoke(provider) if await self.is_local_provider(provider): - await self._vault.delete(provider, collection="providers") + await self._provider_definitions.delete(provider) logger.info("Removed local provider definition: {}", provider) else: logger.info("Revoked bundled provider: {} (definition kept)", provider) diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index 60d4a72..816b09a 100644 --- a/src/authsome/server/dependencies.py +++ b/src/authsome/server/dependencies.py @@ -3,13 +3,13 @@ from __future__ import annotations import os -import secrets from pathlib import Path from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from authsome.server.credential_service import AuthService - from authsome.store.interfaces import AppStore + +from key_value.aio.stores.disk import DiskStore from authsome.auth.models.config import ServerConfig from authsome.identity import current_from_home @@ -23,17 +23,13 @@ LocalIdentityBootstrapService, ) from authsome.server.ownership import HostedOwnershipResolver, LocalOwnershipResolver, OwnershipResolver -from authsome.server.registries import ( - IdentityClaimRegistry, - IdentityRegistry, - PrincipalRegistry, - PrincipalVaultBindingRegistry, - VaultRegistry, -) +from authsome.server.secrets import load_master_secret, load_ui_session_signing_secret +from authsome.server.store import ServerStore +from authsome.server.store import create_server_store as _create_server_store +from authsome.server.store.repositories import IdentityRegistry from authsome.server.urls import build_server_base_url -from authsome.store.local import LocalAppStore from authsome.vault import Vault -from authsome.vault.crypto import AesGcmEncryptionWrapper, DekManager, MasterSecretResolver +from authsome.vault.crypto import AesGcmEncryptionWrapper, DekManager def get_authsome_home() -> Path: @@ -46,46 +42,11 @@ def get_server_home(home: Path | None = None) -> Path: return _get_server_home(home) -def get_server_config_path(home: Path | None = None) -> Path: - """Return the daemon-owned config file path.""" - return get_server_home(home) / "config.json" - - def get_server_log_path(home: Path | None = None) -> Path: """Return the daemon-owned structured log path.""" return _get_server_log_path(home) -def get_identity_registry_path(home: Path | None = None) -> Path: - """Return the daemon-owned identity registry file path.""" - return get_server_home(home) / "identity_registry.json" - - -def get_principal_registry_path(home: Path | None = None) -> Path: - """Return the daemon-owned principal registry file path.""" - return get_server_home(home) / "principal_registry.json" - - -def get_vault_registry_path(home: Path | None = None) -> Path: - """Return the daemon-owned vault registry file path.""" - return get_server_home(home) / "vault_registry.json" - - -def get_identity_claim_registry_path(home: Path | None = None) -> Path: - """Return the daemon-owned identity-claim registry file path.""" - return get_server_home(home) / "identity_claim_registry.json" - - -def get_principal_vault_binding_registry_path(home: Path | None = None) -> Path: - """Return the daemon-owned principal-vault binding registry file path.""" - return get_server_home(home) / "principal_vault_binding_registry.json" - - -def get_ui_session_secret_path(home: Path | None = None) -> Path: - """Return the hosted UI session signing-secret path.""" - return get_server_home(home) / "ui_session_secret.key" - - def get_server_base_url() -> str: """Return the daemon's canonical external base URL.""" return build_server_base_url() @@ -97,62 +58,41 @@ def get_deployment_mode() -> str: return "hosted" if mode == "hosted" else "local" -def load_ui_session_signing_secret(home: Path | None = None) -> str: - """Load or create the hosted UI session signing secret.""" - path = get_ui_session_secret_path(home) - try: - return path.read_text(encoding="utf-8").strip() - except FileNotFoundError: - path.parent.mkdir(parents=True, exist_ok=True) - secret = secrets.token_hex(32) - path.write_text(secret, encoding="utf-8") - os.chmod(path, 0o600) - return secret - - async def get_local_ui_identity(home: Path | None = None) -> str: """Resolve the local active identity handle for the server-rendered UI.""" identity = await current_from_home(home or get_authsome_home()) return identity.handle -def load_server_config(home: Path | None = None) -> ServerConfig: - """Load daemon-owned server config, defaulting when absent or invalid.""" - path = get_server_config_path(home) - try: - return ServerConfig.model_validate_json(path.read_text(encoding="utf-8")) - except Exception: - config = ServerConfig() - save_server_config(config, home) - return config +async def create_store(home: Path | None = None) -> ServerStore: + """Create the server-owned relational Store.""" + return await _create_server_store(home=home or get_authsome_home()) -def save_server_config(config: ServerConfig, home: Path | None = None) -> None: - """Persist daemon-owned server config.""" - path = get_server_config_path(home) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(config.model_dump_json(indent=2), encoding="utf-8") +async def load_server_config(store: ServerStore) -> ServerConfig: + """Load daemon-owned server config from Store.""" + return await store.server_config.load() -async def create_app_store(home: Path | None = None) -> AppStore: - """Create the daemon application store.""" - resolved_home = home or get_authsome_home() - load_server_config(resolved_home) - app_store = LocalAppStore(resolved_home) - await app_store.ensure_initialized() - return app_store +async def save_server_config(store: ServerStore, config: ServerConfig) -> None: + """Persist daemon-owned server config to Store.""" + await store.server_config.save(config) async def list_registered_identity_handles(home: Path | None = None) -> list[str]: """Return identity handles registered with this daemon.""" - registry = IdentityRegistry(get_identity_registry_path(home)) - return await registry.list_handles() + store = await create_store(home) + try: + return await store.identity_registry.list_handles() + finally: + await store.close() -async def create_vault(app_store: AppStore) -> Vault: +async def create_vault(home: Path) -> Vault: """Create the daemon vault from an initialized application store.""" - raw_kv = app_store.kv - secret = MasterSecretResolver(get_server_home(app_store.home)).resolve() + server_home = get_server_home(home) + raw_kv = DiskStore(directory=str(server_home / "kv_store")) + secret = load_master_secret(home) dek = await DekManager().load_or_create(secret, raw_kv) encrypted_kv = AesGcmEncryptionWrapper(raw_kv, dek=dek) return Vault(encrypted_kv) @@ -168,54 +108,38 @@ async def create_auth_service( raise ValueError("create_auth_service requires an explicit identity handle") if not vault_id: raise ValueError("create_auth_service requires an explicit vault_id") - store = await create_app_store(home) - vault = await create_vault(store) - return AuthService(vault=vault, identity=identity, vault_id=vault_id, deployment_mode=get_deployment_mode()) - - -def create_principal_registry(home: Path | None = None) -> PrincipalRegistry: - return PrincipalRegistry(get_principal_registry_path(home)) - - -def create_vault_registry(home: Path | None = None) -> VaultRegistry: - return VaultRegistry(get_vault_registry_path(home)) - - -def create_identity_claim_registry(home: Path | None = None) -> IdentityClaimRegistry: - return IdentityClaimRegistry(get_identity_claim_registry_path(home)) - - -def create_principal_vault_binding_registry(home: Path | None = None) -> PrincipalVaultBindingRegistry: - return PrincipalVaultBindingRegistry(get_principal_vault_binding_registry_path(home)) + store = await create_store(home) + vault = await create_vault(store.home) + return AuthService( + vault=vault, + identity=identity, + vault_id=vault_id, + deployment_mode=get_deployment_mode(), + provider_definitions=store.provider_definitions, + ) -def create_hosted_account_service(home: Path | None = None) -> HostedAccountService: - resolved_home = home or get_authsome_home() +def create_hosted_account_service(store: ServerStore) -> HostedAccountService: return HostedAccountService( - principals=create_principal_registry(resolved_home), - vaults=create_vault_registry(resolved_home), - bindings=create_principal_vault_binding_registry(resolved_home), - jwt_secret=load_ui_session_signing_secret(resolved_home), + principals=store.principals, + vaults=store.vaults, + bindings=store.principal_vault_bindings, + jwt_secret=load_ui_session_signing_secret(store.home), ) -def create_ownership_resolver(home: Path | None = None) -> OwnershipResolver: - resolved_home = home or get_authsome_home() - principals = create_principal_registry(resolved_home) - vaults = create_vault_registry(resolved_home) - claims = create_identity_claim_registry(resolved_home) - bindings = create_principal_vault_binding_registry(resolved_home) +def create_ownership_resolver(store: ServerStore) -> OwnershipResolver: if get_deployment_mode() == "hosted": return HostedOwnershipResolver( - principals=principals, - vaults=vaults, - claims=claims, - bindings=bindings, + principals=store.principals, + vaults=store.vaults, + claims=store.identity_claims, + bindings=store.principal_vault_bindings, ) return LocalOwnershipResolver( - principals=principals, - vaults=vaults, - bindings=bindings, + principals=store.principals, + vaults=store.vaults, + bindings=store.principal_vault_bindings, ) @@ -223,14 +147,13 @@ def create_identity_bootstrap_service( identity_registry: IdentityRegistry, ui_sessions: Any, *, - home: Path | None = None, + store: ServerStore, server_base_url: str | None = None, ) -> IdentityBootstrapService: - resolved_home = home or get_authsome_home() if get_deployment_mode() == "hosted": return HostedIdentityBootstrapService( registry=identity_registry, - claims=create_identity_claim_registry(resolved_home), + claims=store.identity_claims, ui_sessions=ui_sessions, server_base_url=server_base_url or get_server_base_url(), ) diff --git a/src/authsome/server/hosted_auth.py b/src/authsome/server/hosted_auth.py index 0ce9c23..fd3f94b 100644 --- a/src/authsome/server/hosted_auth.py +++ b/src/authsome/server/hosted_auth.py @@ -9,9 +9,9 @@ from argon2 import PasswordHasher from argon2.exceptions import VerificationError, VerifyMismatchError +from authsome.identity.principal import PrincipalRecord from authsome.server.ownership import ensure_principal_default_vault -from authsome.server.registries import ( - PrincipalRecord, +from authsome.server.store.repositories import ( PrincipalRegistry, PrincipalVaultBindingRegistry, VaultRegistry, diff --git a/src/authsome/server/identity_bootstrap.py b/src/authsome/server/identity_bootstrap.py index 3762a33..bf07418 100644 --- a/src/authsome/server/identity_bootstrap.py +++ b/src/authsome/server/identity_bootstrap.py @@ -7,7 +7,7 @@ from authsome.identity.principal import ClaimStatus from authsome.identity.registry import IdentityRegistration -from authsome.server.registries import IdentityClaimRegistry, IdentityRegistry +from authsome.server.store.repositories import IdentityClaimRegistry, IdentityRegistry from authsome.server.ui_sessions import UiSessionStore diff --git a/src/authsome/server/ownership.py b/src/authsome/server/ownership.py index d6bee54..6f7e7b6 100644 --- a/src/authsome/server/ownership.py +++ b/src/authsome/server/ownership.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from authsome.identity.principal import ClaimStatus -from authsome.server.registries import ( +from authsome.server.store.repositories import ( IdentityClaimRegistry, PrincipalRegistry, PrincipalVaultBindingRegistry, diff --git a/src/authsome/server/registries.py b/src/authsome/server/registries.py deleted file mode 100644 index 9bde015..0000000 --- a/src/authsome/server/registries.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Server-owned filesystem-backed registries. - -All state under ~/.authsome/server/ is owned here. -Domain models (IdentityRegistration, PrincipalRecord, etc.) live in -identity/ and are imported freely; only the persistence implementations -belong in this module. -""" - -from __future__ import annotations - -import json -import uuid -from datetime import UTC, datetime -from pathlib import Path - -from pydantic import BaseModel - -from authsome.identity.local import public_key_from_did_key, validate_handle -from authsome.identity.principal import ( - ClaimStatus, - IdentityClaimRecord, - PrincipalRecord, - PrincipalVaultBindingRecord, - VaultRecord, -) -from authsome.identity.registry import IdentityRegistration -from authsome.utils import utc_now - - -class _JsonRegistry[T: BaseModel]: - """Simple JSON-list persistence helper.""" - - def __init__(self, path: Path, model_type: type[T]) -> None: - self._path = path - self._model_type = model_type - - def _load_all(self) -> list[T]: - try: - raw = json.loads(self._path.read_text(encoding="utf-8")) - except FileNotFoundError: - return [] - if not isinstance(raw, list): - return [] - items: list[T] = [] - for item in raw: - try: - items.append(self._model_type.model_validate(item)) - except Exception: - continue - return items - - def _save_all(self, records: list[T]) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text( - json.dumps([r.model_dump(mode="json") for r in records], indent=2), - encoding="utf-8", - ) - - -class IdentityRegistrationError(ValueError): - """Raised when an identity registration conflicts with existing registry state.""" - - -class IdentityRegistry(_JsonRegistry[IdentityRegistration]): - """Filesystem-backed authoritative registry for daemon identity handles.""" - - def __init__(self, path: Path) -> None: - super().__init__(path, IdentityRegistration) - - async def register(self, *, handle: str, did: str) -> IdentityRegistration: - """Register a handle/DID binding, idempotent only for the same pair.""" - handle = validate_handle(handle) - public_key_from_did_key(did) - - registrations = self._load_all() - - existing = next((r for r in registrations if r.handle == handle), None) - if existing is not None: - if existing.did == did: - return existing - raise IdentityRegistrationError(f"Identity handle '{handle}' is already registered") - - for r in registrations: - if r.did == did: - raise IdentityRegistrationError(f"DID is already registered to identity handle '{r.handle}'") - - now = datetime.now(UTC) - registration = IdentityRegistration(handle=handle, did=did, created_at=now, updated_at=now) - registrations.append(registration) - self._save_all(registrations) - return registration - - async def resolve(self, handle: str) -> IdentityRegistration | None: - for r in self._load_all(): - if r.handle == handle: - return r - return None - - async def list_handles(self) -> list[str]: - return sorted(r.handle for r in self._load_all()) - - -class PrincipalRegistry(_JsonRegistry[PrincipalRecord]): - """Filesystem-backed principal registry.""" - - def __init__(self, path: Path) -> None: - super().__init__(path, PrincipalRecord) - - async def get(self, principal_id: str) -> PrincipalRecord | None: - return next((r for r in self._load_all() if r.principal_id == principal_id), None) - - async def get_by_email(self, email: str) -> PrincipalRecord | None: - normalized = email.strip().lower() - return next((r for r in self._load_all() if r.email == normalized), None) - - async def create_by_email(self, email: str, *, password_hash: str | None = None) -> PrincipalRecord: - normalized = email.strip().lower() - if await self.get_by_email(normalized) is not None: - if password_hash is None: - raise ValueError(f"Principal '{normalized}' already exists") - raise ValueError(f"Hosted account '{normalized}' is already registered") - now = utc_now() - record = PrincipalRecord( - principal_id=f"principal_{uuid.uuid4().hex[:12]}", - email=normalized, - password_hash=password_hash, - created_at=now, - updated_at=now, - ) - records = self._load_all() - records.append(record) - self._save_all(records) - return record - - async def update_password(self, principal_id: str, *, password_hash: str) -> PrincipalRecord: - records = self._load_all() - for index, existing in enumerate(records): - if existing.principal_id != principal_id: - continue - records[index] = existing.model_copy(update={"password_hash": password_hash, "updated_at": utc_now()}) - self._save_all(records) - return records[index] - raise ValueError(f"Principal '{principal_id}' not found") - - -class VaultRegistry(_JsonRegistry[VaultRecord]): - """Filesystem-backed vault registry.""" - - def __init__(self, path: Path) -> None: - super().__init__(path, VaultRecord) - - async def get(self, vault_id: str) -> VaultRecord | None: - return next((r for r in self._load_all() if r.vault_id == vault_id), None) - - async def list_all(self) -> list[VaultRecord]: - return self._load_all() - - async def create_default(self) -> VaultRecord: - now = utc_now() - record = VaultRecord( - vault_id=f"vault_{uuid.uuid4().hex[:12]}", - handle="default", - created_at=now, - updated_at=now, - ) - records = self._load_all() - records.append(record) - self._save_all(records) - return record - - -class IdentityClaimRegistry(_JsonRegistry[IdentityClaimRecord]): - """Filesystem-backed identity-claim registry.""" - - def __init__(self, path: Path) -> None: - super().__init__(path, IdentityClaimRecord) - - async def resolve(self, identity_handle: str) -> IdentityClaimRecord | None: - return next((r for r in self._load_all() if r.identity_handle == identity_handle), None) - - async def list_for_principal(self, principal_id: str) -> list[IdentityClaimRecord]: - return sorted( - [r for r in self._load_all() if r.principal_id == principal_id and r.claim_status == ClaimStatus.ACCEPTED], - key=lambda record: record.identity_handle, - ) - - async def require_claim(self, identity_handle: str) -> IdentityClaimRecord: - claim = await self.resolve(identity_handle) - if claim is None: - raise ValueError(f"Identity '{identity_handle}' is not claimed") - return claim - - async def claim_identity(self, identity_handle: str, principal_id: str) -> IdentityClaimRecord: - existing = await self.resolve(identity_handle) - if existing is not None: - if existing.principal_id != principal_id: - raise ValueError(f"Identity '{identity_handle}' is already claimed") - return existing - now = utc_now() - record = IdentityClaimRecord( - identity_handle=identity_handle, - principal_id=principal_id, - claim_status=ClaimStatus.PENDING, - created_at=now, - updated_at=now, - ) - records = self._load_all() - records.append(record) - self._save_all(records) - return record - - async def accept_claim(self, identity_handle: str) -> IdentityClaimRecord: - return await self._set_status(identity_handle, ClaimStatus.ACCEPTED) - - async def reject_claim(self, identity_handle: str) -> IdentityClaimRecord: - return await self._set_status(identity_handle, ClaimStatus.REJECTED) - - async def _set_status(self, identity_handle: str, status: ClaimStatus) -> IdentityClaimRecord: - records = self._load_all() - for i, record in enumerate(records): - if record.identity_handle == identity_handle: - records[i] = record.model_copy(update={"claim_status": status, "updated_at": utc_now()}) - self._save_all(records) - return records[i] - raise ValueError(f"No claim found for identity '{identity_handle}'") - - -class PrincipalVaultBindingRegistry(_JsonRegistry[PrincipalVaultBindingRecord]): - """Filesystem-backed principal-vault binding registry.""" - - def __init__(self, path: Path) -> None: - super().__init__(path, PrincipalVaultBindingRecord) - - async def list_for_principal(self, principal_id: str) -> list[PrincipalVaultBindingRecord]: - return [r for r in self._load_all() if r.principal_id == principal_id] - - async def get_default_vault(self, principal_id: str) -> PrincipalVaultBindingRecord | None: - return next( - (r for r in self._load_all() if r.principal_id == principal_id and r.is_default), - None, - ) - - async def require_default_vault(self, principal_id: str) -> PrincipalVaultBindingRecord: - binding = await self.get_default_vault(principal_id) - if binding is None: - raise ValueError(f"Principal '{principal_id}' has no default vault") - return binding - - async def bind_default(self, principal_id: str, vault_id: str) -> PrincipalVaultBindingRecord: - existing = await self.get_default_vault(principal_id) - if existing is not None: - if existing.vault_id == vault_id: - return existing - raise ValueError(f"Principal '{principal_id}' already has a default vault") - now = utc_now() - record = PrincipalVaultBindingRecord( - principal_id=principal_id, - vault_id=vault_id, - is_default=True, - created_at=now, - updated_at=now, - ) - records = self._load_all() - records.append(record) - self._save_all(records) - return record diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 6dcbc35..3126d97 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -9,7 +9,7 @@ from authsome.identity.proof import POP_AUTH_SCHEME, ProofValidationError, validate_proof_jwt from authsome.server.credential_service import AuthService from authsome.server.dependencies import get_deployment_mode -from authsome.server.registries import VaultRegistry +from authsome.server.store.repositories import VaultRegistry from authsome.server.ui_sessions import UiSessionStore UI_SESSION_COOKIE_NAME = "authsome_ui_session" @@ -32,6 +32,7 @@ async def get_auth_service( principal_id=resolved.principal_id, vault_id=resolved.vault_id, deployment_mode=get_deployment_mode(), + provider_definitions=request.app.state.provider_definition_repository, ) if principal_id is None: @@ -46,6 +47,7 @@ async def get_auth_service( principal_id=principal_id, vault_id=binding.vault_id, deployment_mode=get_deployment_mode(), + provider_definitions=request.app.state.provider_definition_repository, ) diff --git a/src/authsome/server/routes/connections.py b/src/authsome/server/routes/connections.py index 09bddec..701c482 100644 --- a/src/authsome/server/routes/connections.py +++ b/src/authsome/server/routes/connections.py @@ -7,8 +7,8 @@ from authsome.auth.models.enums import ExportFormat from authsome.server.analytics import capture_event from authsome.server.credential_service import AuthService -from authsome.server.registries import VaultRegistry from authsome.server.routes._deps import get_protected_auth_service, get_vault_registry +from authsome.server.store.repositories import VaultRegistry router = APIRouter(tags=["connections"]) diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index 9472108..eb6e1ad 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -36,6 +36,7 @@ def health(request: Request) -> HealthResponse: configured_encryption_mode=effective_source, effective_encryption_source=effective_source, encryption_backend=backend_description, + store_backend=request.app.state.store.backend, ) @@ -50,6 +51,14 @@ async def ready( checks["spec_version"] = "ok" + try: + checks["store"] = "ok" if await request.app.state.store.is_healthy() else "failed" + if checks["store"] == "failed": + issues.append("store: readiness check failed") + except Exception as exc: + checks["store"] = "failed" + issues.append(f"store: {exc}") + vault = request.app.state.vault configured_mode = vault.crypto_source diff --git a/src/authsome/server/routes/identities.py b/src/authsome/server/routes/identities.py index 4144c35..1c8ca70 100644 --- a/src/authsome/server/routes/identities.py +++ b/src/authsome/server/routes/identities.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from authsome.server.analytics import capture_event -from authsome.server.registries import IdentityRegistrationError +from authsome.server.store.repositories import IdentityRegistrationError router = APIRouter(prefix="/identities", tags=["identities"]) diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 75962f0..ac56a8a 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -23,11 +23,7 @@ from authsome.auth.models.provider import ProviderDefinition from authsome.auth.sessions import AuthSession, AuthSessionStore from authsome.server.credential_service import AuthService, is_admin_principal -from authsome.server.dependencies import ( - create_principal_vault_binding_registry, - create_vault_registry, - get_deployment_mode, -) +from authsome.server.dependencies import get_deployment_mode from authsome.server.routes._deps import ( UI_SESSION_COOKIE_NAME, get_auth_service, @@ -283,8 +279,8 @@ async def _provider_connection_groups( if not principal_id: return [] - bindings = create_principal_vault_binding_registry(request.app.state.store.home) - vaults = create_vault_registry(request.app.state.store.home) + bindings = request.app.state.store.principal_vault_bindings + vaults = request.app.state.store.vaults groups: list[dict[str, Any]] = [] for binding in await bindings.list_for_principal(principal_id): @@ -294,6 +290,7 @@ async def _provider_connection_groups( principal_id=principal_id, vault_id=binding.vault_id, deployment_mode=get_deployment_mode(), + provider_definitions=request.app.state.provider_definition_repository, ) provider_connections = next( (group["connections"] for group in await scoped_auth.list_connections() if group["name"] == provider_name), diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py index 265ff17..23f4d74 100644 --- a/src/authsome/server/schemas.py +++ b/src/authsome/server/schemas.py @@ -19,6 +19,7 @@ class HealthResponse(BaseModel): configured_encryption_mode: str | None = None effective_encryption_source: str | None = None encryption_backend: str | None = None + store_backend: str | None = None class ReadyResponse(BaseModel): diff --git a/src/authsome/server/secrets.py b/src/authsome/server/secrets.py new file mode 100644 index 0000000..0f82ff3 --- /dev/null +++ b/src/authsome/server/secrets.py @@ -0,0 +1,120 @@ +"""Server-owned secret resolution for root key material.""" + +from __future__ import annotations + +import base64 +import os +import secrets +from dataclasses import dataclass +from pathlib import Path + +from loguru import logger + +from authsome.paths import get_server_home + +_KEY_SIZE = 32 +_KEYRING_SERVICE = "authsome" + +MASTER_KEY_ENV = "AUTHSOME_MASTER_KEY" +MASTER_KEY_FILE_ENV = "AUTHSOME_MASTER_KEY_FILE" +UI_SESSION_KEY_ENV = "AUTHSOME_UI_SESSION_KEY" +UI_SESSION_KEY_FILE_ENV = "AUTHSOME_UI_SESSION_KEY_FILE" + + +@dataclass(frozen=True) +class ServerSecretSpec: + """Configuration for resolving one server-owned secret.""" + + env_var: str + file_env_var: str + default_filename: str + keyring_username: str + description: str + + +MASTER_SECRET = ServerSecretSpec( + env_var=MASTER_KEY_ENV, + file_env_var=MASTER_KEY_FILE_ENV, + default_filename="master.key", + keyring_username="master_key", + description="master secret", +) + +UI_SESSION_SECRET = ServerSecretSpec( + env_var=UI_SESSION_KEY_ENV, + file_env_var=UI_SESSION_KEY_FILE_ENV, + default_filename="ui_session_secret.key", + keyring_username="ui_session_key", + description="UI session signing secret", +) + + +class ServerSecretResolver: + """Resolve server-owned secrets from env, file, keyring, or generated fallback.""" + + def __init__(self, spec: ServerSecretSpec, home: Path | None = None) -> None: + self._spec = spec + self._server_home = get_server_home(home) + self._default_key_file = self._server_home / spec.default_filename + + def resolve(self) -> str: + value = os.environ.get(self._spec.env_var) + if value and value.strip(): + return value.strip() + + key_file = self._key_file_path() + if key_file.exists(): + content = key_file.read_text(encoding="utf-8").strip() + if content: + return content + + keyring_value = self._read_keyring() + if keyring_value: + return keyring_value + + generated = base64.b64encode(secrets.token_bytes(_KEY_SIZE)).decode("ascii") + if self._write_keyring(generated): + logger.info("Generated and stored new {} in OS keyring", self._spec.description) + else: + self._write_file(self._default_key_file, generated) + logger.info("Generated new {} at {}", self._spec.description, self._default_key_file) + return generated + + def _key_file_path(self) -> Path: + custom = os.environ.get(self._spec.file_env_var) + return Path(custom) if custom else self._default_key_file + + def _read_keyring(self) -> str | None: + try: + import keyring + + return keyring.get_password(_KEYRING_SERVICE, self._spec.keyring_username) + except Exception: + return None + + def _write_keyring(self, value: str) -> bool: + try: + import keyring + + keyring.set_password(_KEYRING_SERVICE, self._spec.keyring_username, value) + return True + except Exception: + return False + + def _write_file(self, path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def load_master_secret(home: Path | None = None) -> str: + """Load or create the server-owned vault master secret.""" + return ServerSecretResolver(MASTER_SECRET, home).resolve() + + +def load_ui_session_signing_secret(home: Path | None = None) -> str: + """Load or create the server-owned hosted UI session signing secret.""" + return ServerSecretResolver(UI_SESSION_SECRET, home).resolve() diff --git a/src/authsome/server/store/__init__.py b/src/authsome/server/store/__init__.py new file mode 100644 index 0000000..245a72a --- /dev/null +++ b/src/authsome/server/store/__init__.py @@ -0,0 +1,6 @@ +"""Server-owned relational Store.""" + +from authsome.server.store.database import StoreDatabase, StoreDatabaseConfig, create_server_store +from authsome.server.store.repositories import ServerStore + +__all__ = ["ServerStore", "StoreDatabase", "StoreDatabaseConfig", "create_server_store"] diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py new file mode 100644 index 0000000..697be0a --- /dev/null +++ b/src/authsome/server/store/database.py @@ -0,0 +1,221 @@ +"""Relational server Store database wiring.""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlparse + +import aiosqlite + +from authsome.paths import get_authsome_home, get_server_home + +StoreBackend = Literal["sqlite", "postgres"] + + +@dataclass(frozen=True) +class StoreDatabaseConfig: + """Resolved relational Store backend configuration.""" + + backend: StoreBackend + dsn: str + home: Path + + +class StoreDatabase: + """Small async database adapter shared by Store repositories.""" + + def __init__(self, *, config: StoreDatabaseConfig, connection: Any) -> None: + self.config = config + self._connection = connection + + @property + def backend(self) -> StoreBackend: + return self.config.backend + + def _sql(self, sql: str) -> str: + if self.backend != "postgres": + return sql + index = 0 + parts: list[str] = [] + for char in sql: + if char == "?": + index += 1 + parts.append(f"${index}") + else: + parts.append(char) + return "".join(parts) + + async def fetch_one(self, sql: str, params: Sequence[Any] = ()) -> dict[str, Any] | None: + if self.backend == "sqlite": + cursor = await self._connection.execute(sql, params) + row = await cursor.fetchone() + await cursor.close() + return dict(row) if row is not None else None + row = await self._connection.fetchrow(self._sql(sql), *params) + return dict(row) if row is not None else None + + async def fetch_all(self, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: + if self.backend == "sqlite": + cursor = await self._connection.execute(sql, params) + rows = await cursor.fetchall() + await cursor.close() + return [dict(row) for row in rows] + rows = await self._connection.fetch(self._sql(sql), *params) + return [dict(row) for row in rows] + + async def execute(self, sql: str, params: Sequence[Any] = ()) -> None: + if self.backend == "sqlite": + await self._connection.execute(sql, params) + await self._connection.commit() + return + await self._connection.execute(self._sql(sql), *params) + + async def execute_many(self, statements: Sequence[str]) -> None: + for statement in statements: + await self.execute(statement) + + @asynccontextmanager + async def transaction(self) -> AsyncIterator[None]: + if self.backend == "sqlite": + await self._connection.execute("BEGIN") + try: + yield + except Exception: + await self._connection.rollback() + raise + else: + await self._connection.commit() + return + async with self._connection.transaction(): + yield + + async def is_healthy(self) -> bool: + try: + row = await self.fetch_one("SELECT 1 AS ok") + return row == {"ok": 1} + except Exception: + return False + + async def close(self) -> None: + await self._connection.close() + + +def resolve_store_database_config(home: Path | None = None, database_url: str | None = None) -> StoreDatabaseConfig: + """Resolve the relational Store backend independently of deployment mode.""" + resolved_home = home or get_authsome_home() + raw_url = database_url if database_url is not None else os.environ.get("AUTHSOME_DATABASE_URL") + if raw_url: + parsed = urlparse(raw_url) + if parsed.scheme in {"postgres", "postgresql"}: + return StoreDatabaseConfig(backend="postgres", dsn=raw_url, home=resolved_home) + if parsed.scheme == "sqlite": + if parsed.path in {"", "/"}: + raise ValueError("sqlite AUTHSOME_DATABASE_URL must include a database path") + return StoreDatabaseConfig(backend="sqlite", dsn=parsed.path, home=resolved_home) + raise ValueError(f"Unsupported AUTHSOME_DATABASE_URL scheme: {parsed.scheme}") + + sqlite_path = get_server_home(resolved_home) / "authsome.db" + return StoreDatabaseConfig(backend="sqlite", dsn=str(sqlite_path), home=resolved_home) + + +async def open_store_database(config: StoreDatabaseConfig) -> StoreDatabase: + """Open the configured Store database and initialize its schema.""" + if config.backend == "sqlite": + db_path = Path(config.dsn) + db_path.parent.mkdir(parents=True, exist_ok=True) + connection = await aiosqlite.connect(db_path) + connection.row_factory = aiosqlite.Row + await connection.execute("PRAGMA foreign_keys = ON") + await connection.commit() + database = StoreDatabase(config=config, connection=connection) + await initialize_schema(database) + return database + + import asyncpg + + connection = await asyncpg.connect(config.dsn) + database = StoreDatabase(config=config, connection=connection) + await initialize_schema(database) + return database + + +def build_schema(backend: StoreBackend) -> list[str]: + """Build schema statements with only the dialect-specific fragments varied.""" + if backend == "postgres": + default_bool = "BOOLEAN NOT NULL DEFAULT FALSE" + true_predicate = "TRUE" + else: + default_bool = "INTEGER NOT NULL DEFAULT 0" + true_predicate = "1" + + return [ + "CREATE TABLE IF NOT EXISTS store_schema_version (version INTEGER PRIMARY KEY)", + "INSERT INTO store_schema_version (version) SELECT 1 " + "WHERE NOT EXISTS (SELECT 1 FROM store_schema_version WHERE version = 1)", + "CREATE TABLE IF NOT EXISTS identity_registrations (" + "handle TEXT PRIMARY KEY, did TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + ")", + "CREATE TABLE IF NOT EXISTS principals (" + "principal_id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT, " + "created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + ")", + "CREATE TABLE IF NOT EXISTS vaults (" + "vault_id TEXT PRIMARY KEY, handle TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + ")", + "CREATE TABLE IF NOT EXISTS identity_claims (" + "identity_handle TEXT PRIMARY KEY, " + "principal_id TEXT NOT NULL REFERENCES principals(principal_id) ON DELETE CASCADE, " + "claim_status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + ")", + "CREATE TABLE IF NOT EXISTS principal_vault_bindings (" + "principal_id TEXT NOT NULL REFERENCES principals(principal_id) ON DELETE CASCADE, " + "vault_id TEXT NOT NULL REFERENCES vaults(vault_id) ON DELETE CASCADE, " + f"is_default {default_bool}, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, " + "PRIMARY KEY (principal_id, vault_id)" + ")", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_principal_vault_default " + f"ON principal_vault_bindings(principal_id) WHERE is_default = {true_predicate}", + "CREATE TABLE IF NOT EXISTS server_config (" + "config_key TEXT PRIMARY KEY, config_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + ")", + "CREATE TABLE IF NOT EXISTS custom_provider_definitions (" + "name TEXT PRIMARY KEY, definition_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + ")", + ] + + +async def initialize_schema(database: StoreDatabase) -> None: + await database.execute_many(build_schema(database.backend)) + + +async def create_server_store(home: Path | None = None, database_url: str | None = None): + """Create the server-owned relational Store.""" + from authsome.server.store.repositories import ( + IdentityClaimRegistry, + IdentityRegistry, + PrincipalRegistry, + PrincipalVaultBindingRegistry, + ProviderDefinitionRepository, + ServerConfigRepository, + ServerStore, + VaultRegistry, + ) + + config = resolve_store_database_config(home=home, database_url=database_url) + database = await open_store_database(config) + return ServerStore( + database=database, + home=config.home, + identity_registry=IdentityRegistry(database), + principals=PrincipalRegistry(database), + vaults=VaultRegistry(database), + identity_claims=IdentityClaimRegistry(database), + principal_vault_bindings=PrincipalVaultBindingRegistry(database), + server_config=ServerConfigRepository(database), + provider_definitions=ProviderDefinitionRepository(database), + ) diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py new file mode 100644 index 0000000..119b5c7 --- /dev/null +++ b/src/authsome/server/store/repositories.py @@ -0,0 +1,401 @@ +"""Typed repositories for server-owned relational Store records.""" + +from __future__ import annotations + +import builtins +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from authsome.auth.models.config import ServerConfig +from authsome.auth.models.provider import ProviderDefinition +from authsome.errors import ProviderAlreadyRegisteredError +from authsome.identity.local import public_key_from_did_key, validate_handle +from authsome.identity.principal import ( + ClaimStatus, + IdentityClaimRecord, + PrincipalRecord, + PrincipalVaultBindingRecord, + VaultRecord, +) +from authsome.identity.registry import IdentityRegistration +from authsome.server.store.database import StoreBackend, StoreDatabase +from authsome.utils import utc_now + + +class IdentityRegistrationError(ValueError): + """Raised when an identity registration conflicts with existing registry state.""" + + +def _dt(value: str) -> datetime: + return datetime.fromisoformat(value) + + +def _dump_dt(value: datetime) -> str: + return value.astimezone(UTC).isoformat() + + +def _bool(value: Any) -> bool: + return bool(value) + + +class IdentityRegistry: + """Relational authoritative registry for daemon identity handles.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def register(self, *, handle: str, did: str) -> IdentityRegistration: + """Register a handle/DID binding, idempotent only for the same pair.""" + handle = validate_handle(handle) + public_key_from_did_key(did) + + existing = await self.resolve(handle) + if existing is not None: + if existing.did == did: + return existing + raise IdentityRegistrationError(f"Identity handle '{handle}' is already registered") + + did_row = await self._db.fetch_one("SELECT handle FROM identity_registrations WHERE did = ?", [did]) + if did_row is not None: + raise IdentityRegistrationError(f"DID is already registered to identity handle '{did_row['handle']}'") + + now = utc_now() + await self._db.execute( + "INSERT INTO identity_registrations (handle, did, created_at, updated_at) VALUES (?, ?, ?, ?)", + [handle, did, _dump_dt(now), _dump_dt(now)], + ) + return IdentityRegistration(handle=handle, did=did, created_at=now, updated_at=now) + + async def resolve(self, handle: str) -> IdentityRegistration | None: + row = await self._db.fetch_one("SELECT * FROM identity_registrations WHERE handle = ?", [handle]) + if row is None: + return None + return IdentityRegistration( + handle=row["handle"], + did=row["did"], + created_at=_dt(row["created_at"]), + updated_at=_dt(row["updated_at"]), + ) + + async def list_handles(self) -> list[str]: + rows = await self._db.fetch_all("SELECT handle FROM identity_registrations ORDER BY handle") + return [row["handle"] for row in rows] + + +class PrincipalRegistry: + """Relational principal registry.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def get(self, principal_id: str) -> PrincipalRecord | None: + row = await self._db.fetch_one("SELECT * FROM principals WHERE principal_id = ?", [principal_id]) + return self._record(row) if row else None + + async def get_by_email(self, email: str) -> PrincipalRecord | None: + normalized = email.strip().lower() + row = await self._db.fetch_one("SELECT * FROM principals WHERE email = ?", [normalized]) + return self._record(row) if row else None + + async def create_by_email(self, email: str, *, password_hash: str | None = None) -> PrincipalRecord: + normalized = email.strip().lower() + if await self.get_by_email(normalized) is not None: + if password_hash is None: + raise ValueError(f"Principal '{normalized}' already exists") + raise ValueError(f"Hosted account '{normalized}' is already registered") + now = utc_now() + record = PrincipalRecord( + principal_id=f"principal_{uuid.uuid4().hex[:12]}", + email=normalized, + password_hash=password_hash, + created_at=now, + updated_at=now, + ) + await self._db.execute( + "INSERT INTO principals (principal_id, email, password_hash, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?)", + [record.principal_id, record.email, record.password_hash, _dump_dt(now), _dump_dt(now)], + ) + return record + + async def update_password(self, principal_id: str, *, password_hash: str) -> PrincipalRecord: + existing = await self.get(principal_id) + if existing is None: + raise ValueError(f"Principal '{principal_id}' not found") + updated = existing.model_copy(update={"password_hash": password_hash, "updated_at": utc_now()}) + await self._db.execute( + "UPDATE principals SET password_hash = ?, updated_at = ? WHERE principal_id = ?", + [updated.password_hash, _dump_dt(updated.updated_at), principal_id], + ) + return updated + + @staticmethod + def _record(row: dict[str, Any]) -> PrincipalRecord: + return PrincipalRecord( + principal_id=row["principal_id"], + email=row["email"], + password_hash=row["password_hash"], + created_at=_dt(row["created_at"]), + updated_at=_dt(row["updated_at"]), + ) + + +class VaultRegistry: + """Relational vault registry.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def get(self, vault_id: str) -> VaultRecord | None: + row = await self._db.fetch_one("SELECT * FROM vaults WHERE vault_id = ?", [vault_id]) + return self._record(row) if row else None + + async def list_all(self) -> list[VaultRecord]: + rows = await self._db.fetch_all("SELECT * FROM vaults ORDER BY created_at, vault_id") + return [self._record(row) for row in rows] + + async def create_default(self) -> VaultRecord: + now = utc_now() + record = VaultRecord( + vault_id=f"vault_{uuid.uuid4().hex[:12]}", + handle="default", + created_at=now, + updated_at=now, + ) + await self._db.execute( + "INSERT INTO vaults (vault_id, handle, created_at, updated_at) VALUES (?, ?, ?, ?)", + [record.vault_id, record.handle, _dump_dt(now), _dump_dt(now)], + ) + return record + + @staticmethod + def _record(row: dict[str, Any]) -> VaultRecord: + return VaultRecord( + vault_id=row["vault_id"], + handle=row["handle"], + created_at=_dt(row["created_at"]), + updated_at=_dt(row["updated_at"]), + ) + + +class IdentityClaimRegistry: + """Relational identity-claim registry.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def resolve(self, identity_handle: str) -> IdentityClaimRecord | None: + row = await self._db.fetch_one("SELECT * FROM identity_claims WHERE identity_handle = ?", [identity_handle]) + return self._record(row) if row else None + + async def list_for_principal(self, principal_id: str) -> list[IdentityClaimRecord]: + rows = await self._db.fetch_all( + "SELECT * FROM identity_claims WHERE principal_id = ? AND claim_status = ? ORDER BY identity_handle", + [principal_id, ClaimStatus.ACCEPTED.value], + ) + return [self._record(row) for row in rows] + + async def require_claim(self, identity_handle: str) -> IdentityClaimRecord: + claim = await self.resolve(identity_handle) + if claim is None: + raise ValueError(f"Identity '{identity_handle}' is not claimed") + return claim + + async def claim_identity(self, identity_handle: str, principal_id: str) -> IdentityClaimRecord: + existing = await self.resolve(identity_handle) + if existing is not None: + if existing.principal_id != principal_id: + raise ValueError(f"Identity '{identity_handle}' is already claimed") + return existing + now = utc_now() + record = IdentityClaimRecord( + identity_handle=identity_handle, + principal_id=principal_id, + claim_status=ClaimStatus.PENDING, + created_at=now, + updated_at=now, + ) + await self._db.execute( + "INSERT INTO identity_claims " + "(identity_handle, principal_id, claim_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + [ + record.identity_handle, + record.principal_id, + record.claim_status.value, + _dump_dt(now), + _dump_dt(now), + ], + ) + return record + + async def accept_claim(self, identity_handle: str) -> IdentityClaimRecord: + return await self._set_status(identity_handle, ClaimStatus.ACCEPTED) + + async def reject_claim(self, identity_handle: str) -> IdentityClaimRecord: + return await self._set_status(identity_handle, ClaimStatus.REJECTED) + + async def _set_status(self, identity_handle: str, status: ClaimStatus) -> IdentityClaimRecord: + existing = await self.resolve(identity_handle) + if existing is None: + raise ValueError(f"No claim found for identity '{identity_handle}'") + updated = existing.model_copy(update={"claim_status": status, "updated_at": utc_now()}) + await self._db.execute( + "UPDATE identity_claims SET claim_status = ?, updated_at = ? WHERE identity_handle = ?", + [status.value, _dump_dt(updated.updated_at), identity_handle], + ) + return updated + + @staticmethod + def _record(row: dict[str, Any]) -> IdentityClaimRecord: + return IdentityClaimRecord( + identity_handle=row["identity_handle"], + principal_id=row["principal_id"], + claim_status=ClaimStatus(row["claim_status"]), + created_at=_dt(row["created_at"]), + updated_at=_dt(row["updated_at"]), + ) + + +class PrincipalVaultBindingRegistry: + """Relational principal-vault binding registry.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def list_for_principal(self, principal_id: str) -> list[PrincipalVaultBindingRecord]: + rows = await self._db.fetch_all( + "SELECT * FROM principal_vault_bindings WHERE principal_id = ? ORDER BY created_at, vault_id", + [principal_id], + ) + return [self._record(row) for row in rows] + + async def get_default_vault(self, principal_id: str) -> PrincipalVaultBindingRecord | None: + row = await self._db.fetch_one( + "SELECT * FROM principal_vault_bindings WHERE principal_id = ? AND is_default = ?", + [principal_id, True], + ) + return self._record(row) if row else None + + async def require_default_vault(self, principal_id: str) -> PrincipalVaultBindingRecord: + binding = await self.get_default_vault(principal_id) + if binding is None: + raise ValueError(f"Principal '{principal_id}' has no default vault") + return binding + + async def bind_default(self, principal_id: str, vault_id: str) -> PrincipalVaultBindingRecord: + existing = await self.get_default_vault(principal_id) + if existing is not None: + if existing.vault_id == vault_id: + return existing + raise ValueError(f"Principal '{principal_id}' already has a default vault") + now = utc_now() + record = PrincipalVaultBindingRecord( + principal_id=principal_id, + vault_id=vault_id, + is_default=True, + created_at=now, + updated_at=now, + ) + await self._db.execute( + "INSERT INTO principal_vault_bindings " + "(principal_id, vault_id, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + [record.principal_id, record.vault_id, record.is_default, _dump_dt(now), _dump_dt(now)], + ) + return record + + @staticmethod + def _record(row: dict[str, Any]) -> PrincipalVaultBindingRecord: + return PrincipalVaultBindingRecord( + principal_id=row["principal_id"], + vault_id=row["vault_id"], + is_default=_bool(row["is_default"]), + created_at=_dt(row["created_at"]), + updated_at=_dt(row["updated_at"]), + ) + + +class ServerConfigRepository: + """Relational server config repository.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def load(self) -> ServerConfig: + row = await self._db.fetch_one("SELECT config_json FROM server_config WHERE config_key = ?", ["global"]) + if row is None: + config = ServerConfig() + await self.save(config) + return config + return ServerConfig.model_validate_json(row["config_json"]) + + async def save(self, config: ServerConfig) -> None: + now = _dump_dt(utc_now()) + await self._db.execute( + "INSERT INTO server_config (config_key, config_json, created_at, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(config_key) DO UPDATE SET config_json = excluded.config_json, " + "updated_at = excluded.updated_at", + ["global", config.model_dump_json(), now, now], + ) + + +class ProviderDefinitionRepository: + """Relational repository for custom provider definitions.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def get(self, name: str) -> ProviderDefinition | None: + row = await self._db.fetch_one("SELECT definition_json FROM custom_provider_definitions WHERE name = ?", [name]) + return ProviderDefinition.model_validate_json(row["definition_json"]) if row else None + + async def list(self) -> builtins.list[ProviderDefinition]: + rows = await self._db.fetch_all("SELECT definition_json FROM custom_provider_definitions ORDER BY name") + return [ProviderDefinition.model_validate_json(row["definition_json"]) for row in rows] + + async def save(self, definition: ProviderDefinition, *, force: bool = False) -> None: + existing = await self.get(definition.name) + if existing is not None and not force: + raise ProviderAlreadyRegisteredError(definition.name) + now = _dump_dt(utc_now()) + await self._db.execute( + "INSERT INTO custom_provider_definitions (name, definition_json, created_at, updated_at) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(name) DO UPDATE SET definition_json = excluded.definition_json, " + "updated_at = excluded.updated_at", + [definition.name, definition.model_dump_json(indent=2, exclude_none=True), now, now], + ) + + async def delete(self, name: str) -> bool: + existing = await self.get(name) + if existing is None: + return False + await self._db.execute("DELETE FROM custom_provider_definitions WHERE name = ?", [name]) + return True + + +@dataclass +class ServerStore: + """Composition root for server Store repositories.""" + + database: StoreDatabase + home: Path + identity_registry: IdentityRegistry + principals: PrincipalRegistry + vaults: VaultRegistry + identity_claims: IdentityClaimRegistry + principal_vault_bindings: PrincipalVaultBindingRegistry + server_config: ServerConfigRepository + provider_definitions: ProviderDefinitionRepository + + @property + def backend(self) -> StoreBackend: + return self.database.backend + + async def is_healthy(self) -> bool: + return await self.database.is_healthy() + + async def close(self) -> None: + await self.database.close() diff --git a/src/authsome/store/__init__.py b/src/authsome/store/__init__.py deleted file mode 100644 index 61c303e..0000000 --- a/src/authsome/store/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Authsome store - Stores profiles, configs and providers.""" - -# TODO: Stores are server property. They tell where to store configs, profiles, providers, etc etc -# For dev mode: We use sqlite on the local filesystem in AUTHLIB_HOME -# TODO(future): We can add remote stores / backend like postgres diff --git a/src/authsome/store/interfaces.py b/src/authsome/store/interfaces.py deleted file mode 100644 index ba34d1e..0000000 --- a/src/authsome/store/interfaces.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Unified storage interfaces for Authsome.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from pathlib import Path - -from key_value.aio.protocols.key_value import AsyncKeyValue - - -# TODO: Name app store doesn't seem correct at all -class AppStore(ABC): - """Storage backend for the encrypted vault KV.""" - - @property - @abstractmethod - def home(self) -> Path: - """Base directory for this storage system.""" - ... - - @property - @abstractmethod - def kv(self) -> AsyncKeyValue: - """The underlying async key-value store.""" - ... - - # ── Initialization ──────────────────────────────────────────────────── - - @abstractmethod - async def ensure_initialized(self) -> None: - """Seed the store with version marker and default config.""" - ... - - @abstractmethod - async def is_healthy(self) -> bool: - """Check if the store is accessible.""" - ... - - @abstractmethod - async def check_integrity(self) -> bool: - """Perform a health check on the storage medium.""" - ... - - @abstractmethod - async def close(self) -> None: - """Close all underlying storage connections.""" - ... diff --git a/src/authsome/store/local.py b/src/authsome/store/local.py deleted file mode 100644 index 6f44421..0000000 --- a/src/authsome/store/local.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Local disk-backed implementation of the AppStore.""" - -from __future__ import annotations - -from pathlib import Path - -from key_value.aio.protocols.key_value import AsyncKeyValue -from key_value.aio.stores.disk import DiskStore - -from authsome.paths import get_server_home -from authsome.store.interfaces import AppStore - -_CONFIG_COLLECTION = "config" - - -class LocalAppStore(AppStore): - """Disk-backed AppStore for the daemon vault KV.""" - - def __init__(self, home_dir: Path) -> None: - self._home = home_dir - self._home.mkdir(parents=True, exist_ok=True) - self._server_home = get_server_home(home_dir) - self._server_home.mkdir(parents=True, exist_ok=True) - self._store = DiskStore(directory=str(self._server_home / "kv_store")) - - @property - def home(self) -> Path: - return self._home - - @property - def server_home(self) -> Path: - return self._server_home - - @property - def kv(self) -> AsyncKeyValue: - return self._store - - # ── Initialization ──────────────────────────────────────────────────── - - async def ensure_initialized(self) -> None: - if await self._store.get("version", collection=_CONFIG_COLLECTION) is not None: - return - await self._store.put("version", {"data": "1"}, collection=_CONFIG_COLLECTION) - - async def is_healthy(self) -> bool: - return True - - async def check_integrity(self) -> bool: - return True - - async def close(self) -> None: - close = getattr(self._store, "close", None) - if callable(close): - await self._store.close() diff --git a/src/authsome/vault/crypto.py b/src/authsome/vault/crypto.py index 06d6623..87a884b 100644 --- a/src/authsome/vault/crypto.py +++ b/src/authsome/vault/crypto.py @@ -14,9 +14,7 @@ from __future__ import annotations import base64 -import os import secrets -from pathlib import Path from typing import Any import argon2.low_level @@ -34,11 +32,6 @@ _META_COLLECTION = "__vault_meta__" _DEK_KEY = "__dek__" -_MASTER_KEY_ENV = "AUTHSOME_MASTER_KEY" -_MASTER_KEY_FILE_ENV = "AUTHSOME_MASTER_KEY_FILE" -_KEYRING_SERVICE = "authsome" -_KEYRING_USERNAME = "master_key" - _ARGON2_MEMORY_COST = 65536 # 64 MB _ARGON2_TIME_COST = 3 _ARGON2_PARALLELISM = 4 @@ -46,72 +39,6 @@ ENCRYPTION_VERSION = 1 -class MasterSecretResolver: - """Resolves the vault master secret from env, file, keyring, or auto-generates one. - - Resolution order: - 1. AUTHSOME_MASTER_KEY env var - 2. File at AUTHSOME_MASTER_KEY_FILE env var, or default ~/.authsome/server/master.key - 3. OS keyring - 4. Auto-generate → persist to keyring, or to the default file if keyring unavailable - """ - - def __init__(self, server_home: Path) -> None: - self._default_key_file = server_home / "master.key" - - def resolve(self) -> str: - value = os.environ.get(_MASTER_KEY_ENV) - if value and value.strip(): - return value.strip() - - key_file = self._key_file_path() - if key_file.exists(): - content = key_file.read_text(encoding="utf-8").strip() - if content: - return content - - keyring_value = self._read_keyring() - if keyring_value: - return keyring_value - - generated = base64.b64encode(secrets.token_bytes(_KEY_SIZE)).decode("ascii") - if self._write_keyring(generated): - logger.info("Generated and stored new master secret in OS keyring") - else: - self._write_file(self._default_key_file, generated) - logger.info("Generated new master secret at {}", self._default_key_file) - return generated - - def _key_file_path(self) -> Path: - custom = os.environ.get(_MASTER_KEY_FILE_ENV) - return Path(custom) if custom else self._default_key_file - - def _read_keyring(self) -> str | None: - try: - import keyring - - return keyring.get_password(_KEYRING_SERVICE, _KEYRING_USERNAME) - except Exception: - return None - - def _write_keyring(self, value: str) -> bool: - try: - import keyring - - keyring.set_password(_KEYRING_SERVICE, _KEYRING_USERNAME, value) - return True - except Exception: - return False - - def _write_file(self, path: Path, value: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(value, encoding="utf-8") - try: - os.chmod(path, 0o600) - except OSError: - pass - - class DekManager: """Bootstraps and loads the vault DEK from the raw (unencrypted) KV store. diff --git a/tests/auth/test_browser_service.py b/tests/auth/test_browser_service.py index a642d91..0f8727d 100644 --- a/tests/auth/test_browser_service.py +++ b/tests/auth/test_browser_service.py @@ -17,7 +17,9 @@ def _svc() -> AuthService: vault = MagicMock() - return AuthService(vault=vault, identity="agent", principal_id="p1", vault_id="v1") + return AuthService( + vault=vault, identity="agent", principal_id="p1", vault_id="v1", provider_definitions=[_provider()] + ) def _provider(validate_url: str | None = None) -> ProviderDefinition: diff --git a/tests/auth/test_service.py b/tests/auth/test_service.py index f8682f3..4461423 100644 --- a/tests/auth/test_service.py +++ b/tests/auth/test_service.py @@ -15,6 +15,20 @@ from authsome.utils import utc_now +class EmptyProviderDefinitions: + async def get(self, name: str): # noqa: ANN001, ANN201 + return None + + async def list(self): # noqa: ANN201 + return [] + + async def save(self, definition, *, force: bool = False) -> None: # noqa: ANN001 + raise AssertionError("unexpected provider definition save") + + async def delete(self, name: str) -> bool: + return False + + @pytest.mark.asyncio class TestAuthServiceRefreshLogs: """Tests validating that token refresh failure writes correct logs and audit trails.""" @@ -29,7 +43,12 @@ def audit_log(self, tmp_path: Path) -> Path: @pytest.fixture def service(self) -> AuthService: mock_vault = mock.AsyncMock() - return AuthService(mock_vault, identity="test-profile", vault_id="test-vault") + return AuthService( + mock_vault, + identity="test-profile", + vault_id="test-vault", + provider_definitions=EmptyProviderDefinitions(), + ) async def test_refresh_failure_fallback_available(self, audit_log: Path, service: AuthService): """Verify behavior when refresh fails but current token is valid (close to expiry).""" @@ -119,6 +138,7 @@ def test_auth_service_allows_missing_identity() -> None: identity=None, principal_id="principal_1", vault_id="vault_default", + provider_definitions=EmptyProviderDefinitions(), ) assert service.identity is None @@ -130,5 +150,13 @@ def test_auth_service_scopes_collection_by_vault_id() -> None: identity="agent-a", principal_id="principal_1", vault_id="vault_default", + provider_definitions=EmptyProviderDefinitions(), ) assert service._coll == "vault:vault_default" + + +def test_auth_service_requires_provider_definitions() -> None: + mock_vault = mock.AsyncMock() + + with pytest.raises(TypeError): + AuthService(mock_vault, identity="agent-a", vault_id="vault_default") # type: ignore[call-arg] diff --git a/tests/auth/test_service_provider_clients.py b/tests/auth/test_service_provider_clients.py index f8e481b..ba39b52 100644 --- a/tests/auth/test_service_provider_clients.py +++ b/tests/auth/test_service_provider_clients.py @@ -15,15 +15,30 @@ from authsome.identity import create_identity from authsome.server.credential_service import AuthService from authsome.server.dependencies import ( - create_app_store, + create_store, create_vault, - create_vault_registry, - get_identity_registry_path, ) -from authsome.server.registries import IdentityRegistry from authsome.utils import build_store_key +class EmptyProviderDefinitions: + async def get(self, name: str): # noqa: ANN001, ANN201 + return None + + async def list(self): # noqa: ANN201 + return [] + + async def save(self, definition, *, force: bool = False) -> None: # noqa: ANN001 + raise AssertionError("unexpected provider definition save") + + async def delete(self, name: str) -> bool: + return False + + +def _service(vault, **kwargs) -> AuthService: # noqa: ANN001, ANN003 + return AuthService(vault, provider_definitions=EmptyProviderDefinitions(), **kwargs) + + def _make_provider(*, flow: FlowType = FlowType.PKCE) -> ProviderDefinition: return ProviderDefinition( name="github", @@ -52,7 +67,7 @@ def _make_session(*, flow_type: FlowType) -> AuthSession: async def test_get_provider_client_reads_from_server_scope() -> None: vault = mock.AsyncMock() vault.get.return_value = ProviderClientRecord(provider="github", client_id="cid").model_dump_json() - service = AuthService(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042") record = await service.get_provider_client("github") @@ -65,7 +80,7 @@ async def test_get_provider_client_reads_from_server_scope() -> None: async def test_save_inputs_persists_provider_client_to_server_scope() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042") session = _make_session(flow_type=FlowType.PKCE) await service.save_inputs( @@ -91,7 +106,7 @@ async def test_save_inputs_persists_provider_client_to_server_scope() -> None: async def test_save_inputs_with_scopes_only_writes_server_record() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042") session = _make_session(flow_type=FlowType.PKCE) await service.save_inputs(session, {"scopes": "repo,read:user"}) @@ -105,7 +120,7 @@ async def test_save_inputs_with_scopes_only_writes_server_record() -> None: @pytest.mark.asyncio async def test_get_required_inputs_skips_scope_prompt_when_server_scopes_exist() -> None: vault = mock.AsyncMock() - service = AuthService(vault, identity="second-identity") + service = _service(vault, identity="second-identity") session = _make_session(flow_type=FlowType.PKCE) with mock.patch.object( @@ -129,7 +144,7 @@ async def test_get_required_inputs_skips_scope_prompt_when_server_scopes_exist() async def test_pkce_client_credentials_prompt_id_then_secret() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042") session = _make_session(flow_type=FlowType.PKCE) with mock.patch.object(service, "get_provider", new=mock.AsyncMock(return_value=_make_provider())): @@ -144,7 +159,7 @@ async def test_pkce_client_credentials_prompt_id_then_secret() -> None: async def test_update_provider_configuration_persists_default_scopes_when_omitted() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042") with mock.patch.object(service, "get_provider", new=mock.AsyncMock(return_value=_make_provider())): changed = await service.update_provider_configuration( @@ -164,7 +179,7 @@ async def test_update_provider_configuration_persists_default_scopes_when_omitte async def test_update_provider_configuration_persists_submitted_scopes() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042") with mock.patch.object(service, "get_provider", new=mock.AsyncMock(return_value=_make_provider())): changed = await service.update_provider_configuration( @@ -192,13 +207,13 @@ async def put_value(key: str, value: str, *, collection: str) -> None: vault.put.side_effect = put_value monkeypatch.setenv("AUTHSOME_ADMIN_PRINCIPALS", "principal_admin") - admin_service = AuthService( + admin_service = _service( vault, identity=None, principal_id="principal_admin", deployment_mode="hosted", ) - identity_service = AuthService( + identity_service = _service( vault, identity="steady-wisely-boldly-0042", principal_id="principal_user", @@ -227,7 +242,7 @@ async def test_begin_login_flow_reuses_server_scopes() -> None: client_secret="secret", scopes=["repo", "read:user"], ).model_dump_json() - service = AuthService(vault, identity="second-identity") + service = _service(vault, identity="second-identity") session = _make_session(flow_type=FlowType.PKCE) handler = mock.AsyncMock() @@ -244,7 +259,7 @@ async def test_begin_login_flow_reuses_server_scopes() -> None: async def test_resume_login_flow_saves_dcr_client_record_to_server_scope() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042") session = _make_session(flow_type=FlowType.DCR_PKCE) session.payload["base_url"] = "https://api.github.example" @@ -293,7 +308,7 @@ async def test_resume_login_flow_saves_dcr_client_record_to_server_scope() -> No async def test_hosted_save_inputs_rejects_shared_client_mutation() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") + service = _service(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") session = _make_session(flow_type=FlowType.PKCE) with pytest.raises(OperationNotAllowedError): @@ -307,7 +322,7 @@ async def test_hosted_save_inputs_rejects_shared_client_mutation() -> None: async def test_hosted_save_inputs_rejects_scopes_only_server_write() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") + service = _service(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") session = _make_session(flow_type=FlowType.PKCE) with pytest.raises(OperationNotAllowedError): @@ -318,7 +333,7 @@ async def test_hosted_save_inputs_rejects_scopes_only_server_write() -> None: async def test_hosted_resume_login_flow_rejects_dcr_client_persistence() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = AuthService(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") + service = _service(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") session = _make_session(flow_type=FlowType.DCR_PKCE) connection = ConnectionRecord( @@ -350,14 +365,12 @@ async def test_hosted_resume_login_flow_rejects_dcr_client_persistence() -> None @pytest.mark.asyncio async def test_revoke_local_deletes_shared_client_and_all_identity_connections(tmp_path) -> None: first_identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - store = await create_app_store(tmp_path) - registry = IdentityRegistry(get_identity_registry_path(tmp_path)) - await registry.register(handle=first_identity.handle, did=first_identity.did) - vault_registry = create_vault_registry(tmp_path) - primary_vault = await vault_registry.create_default() - secondary_vault = await vault_registry.create_default() - - vault = await create_vault(store) + store = await create_store(tmp_path) + await store.identity_registry.register(handle=first_identity.handle, did=first_identity.did) + primary_vault = await store.vaults.create_default() + secondary_vault = await store.vaults.create_default() + + vault = await create_vault(store.home) try: service = AuthService( vault, @@ -365,6 +378,7 @@ async def test_revoke_local_deletes_shared_client_and_all_identity_connections(t principal_id="principal_1", vault_id=primary_vault.vault_id, deployment_mode="local", + provider_definitions=store.provider_definitions, ) primary_connection = ConnectionRecord( diff --git a/tests/auth/test_service_provider_definitions.py b/tests/auth/test_service_provider_definitions.py new file mode 100644 index 0000000..188d0db --- /dev/null +++ b/tests/auth/test_service_provider_definitions.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from authsome.auth.models.connection import ProviderClientRecord +from authsome.auth.models.enums import AuthType, FlowType +from authsome.auth.models.provider import ApiKeyConfig, ProviderDefinition +from authsome.server.credential_service import AuthService +from authsome.server.store import create_server_store +from authsome.vault import Vault + + +def _provider(name: str = "custom-api") -> ProviderDefinition: + return ProviderDefinition( + name=name, + display_name="Custom API", + auth_type=AuthType.API_KEY, + flow=FlowType.API_KEY, + api_key=ApiKeyConfig(header_name="Authorization"), + ) + + +@pytest.mark.asyncio +async def test_custom_provider_definition_is_stored_in_store_not_vault(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + vault = AsyncMock(spec=Vault) + service = AuthService( + vault=vault, + identity="steady-wisely-boldly-0042", + vault_id="vault_test", + provider_definitions=store.provider_definitions, + ) + + await service.register_provider(_provider()) + + assert await store.provider_definitions.get("custom-api") is not None + vault.put.assert_not_awaited() + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_provider_client_credentials_still_use_vault(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + vault = AsyncMock(spec=Vault) + service = AuthService( + vault=vault, + identity="steady-wisely-boldly-0042", + vault_id="vault_test", + provider_definitions=store.provider_definitions, + ) + + await service._save_provider_client_credentials(ProviderClientRecord(provider="github", client_id="cid")) + + vault.put.assert_awaited_once() + assert vault.put.await_args.kwargs == {"collection": "server"} + finally: + await store.close() diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 72dd6ae..f663290 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import json from pathlib import Path @@ -10,7 +9,6 @@ from authsome.cli.client_config import ClientConfig, load_client_config, save_client_config from authsome.cli.main import cli from authsome.identity import create_identity, mark_registered -from authsome.store.local import LocalAppStore def test_init_removes_legacy_default_state_and_registers_identity( @@ -23,8 +21,6 @@ def test_init_removes_legacy_default_state_and_registers_identity( (identities / "default.json").write_text("{}", encoding="utf-8") (identities / "default.key").write_text("legacy\n", encoding="utf-8") - asyncio.run(LocalAppStore(tmp_path).ensure_initialized()) - created = create_identity(tmp_path, "steady-wisely-boldly-0042") mark_registered(tmp_path, created.handle) mock_client.ensure_identity_ready.return_value = created @@ -53,7 +49,6 @@ def test_init_skips_registration_for_registered_active_profile( mock_client, tmp_path: Path, ) -> None: - asyncio.run(LocalAppStore(tmp_path).ensure_initialized()) identity = create_identity(tmp_path, "steady-wisely-boldly-0042") mark_registered(tmp_path, identity.handle) save_client_config(tmp_path, ClientConfig(active_identity=identity.handle)) diff --git a/tests/identity/test_registry.py b/tests/identity/test_registry.py index 26bf4e6..c3d15d6 100644 --- a/tests/identity/test_registry.py +++ b/tests/identity/test_registry.py @@ -5,37 +5,48 @@ import pytest from authsome.identity.principal import ClaimStatus -from authsome.server.registries import ( - IdentityClaimRegistry, - PrincipalRegistry, - PrincipalVaultBindingRegistry, - VaultRegistry, -) +from authsome.server.store import ServerStore, create_server_store +from authsome.server.store.repositories import IdentityClaimRegistry -@pytest.mark.asyncio -async def test_claim_creates_principal_and_default_vault(tmp_path: Path) -> None: - principals = PrincipalRegistry(tmp_path / "principals.json") - claims = IdentityClaimRegistry(tmp_path / "claims.json") - vaults = VaultRegistry(tmp_path / "vaults.json") - bindings = PrincipalVaultBindingRegistry(tmp_path / "bindings.json") +async def _store(tmp_path: Path) -> ServerStore: + return await create_server_store(home=tmp_path) - principal = await principals.create_by_email("dev@example.com") - vault = await vaults.create_default() - binding = await bindings.bind_default(principal.principal_id, vault.vault_id) - claim = await claims.claim_identity("steady-wisely-boldly-0042", principal.principal_id) - assert principal.email == "dev@example.com" - assert vault.handle == "default" - assert binding.is_default is True - assert claim.identity_handle == "steady-wisely-boldly-0042" - assert claim.claim_status == ClaimStatus.PENDING +@pytest.mark.asyncio +async def test_claim_creates_principal_and_default_vault(tmp_path: Path) -> None: + store = await _store(tmp_path) + principals = store.principals + claims = store.identity_claims + vaults = store.vaults + bindings = store.principal_vault_bindings + + try: + principal = await principals.create_by_email("dev@example.com") + vault = await vaults.create_default() + binding = await bindings.bind_default(principal.principal_id, vault.vault_id) + claim = await claims.claim_identity("steady-wisely-boldly-0042", principal.principal_id) + + assert principal.email == "dev@example.com" + assert vault.handle == "default" + assert binding.is_default is True + assert claim.identity_handle == "steady-wisely-boldly-0042" + assert claim.claim_status == ClaimStatus.PENDING + finally: + await store.close() @pytest.mark.asyncio async def test_claim_is_immutable_for_existing_identity(tmp_path: Path) -> None: - claims = IdentityClaimRegistry(tmp_path / "claims.json") - await claims.claim_identity("steady-wisely-boldly-0042", "principal_1") - - with pytest.raises(ValueError, match="already claimed"): - await claims.claim_identity("steady-wisely-boldly-0042", "principal_2") + store = await _store(tmp_path) + claims: IdentityClaimRegistry = store.identity_claims + + try: + principal = await store.principals.create_by_email("dev@example.com") + other = await store.principals.create_by_email("ops@example.com") + await claims.claim_identity("steady-wisely-boldly-0042", principal.principal_id) + + with pytest.raises(ValueError, match="already claimed"): + await claims.claim_identity("steady-wisely-boldly-0042", other.principal_id) + finally: + await store.close() diff --git a/tests/server/test_hosted_auth.py b/tests/server/test_hosted_auth.py index 7189cc6..e9b93ca 100644 --- a/tests/server/test_hosted_auth.py +++ b/tests/server/test_hosted_auth.py @@ -4,80 +4,96 @@ import pytest from authsome.server.hosted_auth import UI_TOKEN_AUDIENCE, HostedAccountService -from authsome.server.registries import ( - PrincipalRegistry, - PrincipalVaultBindingRegistry, - VaultRegistry, -) - - -def _service(tmp_path: Path) -> HostedAccountService: - return HostedAccountService( - principals=PrincipalRegistry(tmp_path / "principal_registry.json"), - vaults=VaultRegistry(tmp_path / "vault_registry.json"), - bindings=PrincipalVaultBindingRegistry(tmp_path / "principal_vault_binding_registry.json"), - jwt_secret="test-secret", +from authsome.server.store import ServerStore, create_server_store + + +async def _service(tmp_path: Path) -> tuple[HostedAccountService, ServerStore]: + store = await create_server_store(home=tmp_path) + return ( + HostedAccountService( + principals=store.principals, + vaults=store.vaults, + bindings=store.principal_vault_bindings, + jwt_secret="test-secret", + ), + store, ) +async def _close(store: ServerStore) -> None: + await store.close() + + @pytest.mark.asyncio async def test_register_creates_principal_and_password_hash(tmp_path: Path) -> None: - service = _service(tmp_path) + service, store = await _service(tmp_path) - principal = await service.register(email="Dev@Example.com", password="password-1") - principals = PrincipalRegistry(tmp_path / "principal_registry.json") - stored = await principals.get(principal.principal_id) - bindings = PrincipalVaultBindingRegistry(tmp_path / "principal_vault_binding_registry.json") - binding = await bindings.get_default_vault(principal.principal_id) + try: + principal = await service.register(email="Dev@Example.com", password="password-1") + stored = await store.principals.get(principal.principal_id) + binding = await store.principal_vault_bindings.get_default_vault(principal.principal_id) - assert principal.email == "dev@example.com" - assert principal.principal_id.startswith("principal_") - assert principal.password_hash != "password-1" - assert stored is not None - assert stored.password_hash == principal.password_hash - assert binding is not None + assert principal.email == "dev@example.com" + assert principal.principal_id.startswith("principal_") + assert principal.password_hash != "password-1" + assert stored is not None + assert stored.password_hash == principal.password_hash + assert binding is not None + finally: + await _close(store) @pytest.mark.asyncio async def test_register_rejects_duplicate_email(tmp_path: Path) -> None: - service = _service(tmp_path) + service, store = await _service(tmp_path) - await service.register(email="dev@example.com", password="password-1") + try: + await service.register(email="dev@example.com", password="password-1") - with pytest.raises(ValueError, match="already registered"): - await service.register(email="DEV@example.com", password="password-2") + with pytest.raises(ValueError, match="already registered"): + await service.register(email="DEV@example.com", password="password-2") + finally: + await _close(store) @pytest.mark.asyncio async def test_register_adds_password_to_existing_passwordless_principal(tmp_path: Path) -> None: - principals = PrincipalRegistry(tmp_path / "principal_registry.json") - existing = await principals.create_by_email("dev@example.com") - service = _service(tmp_path) + service, store = await _service(tmp_path) - registered = await service.register(email="dev@example.com", password="password-1") + try: + existing = await store.principals.create_by_email("dev@example.com") + registered = await service.register(email="dev@example.com", password="password-1") - assert registered.principal_id == existing.principal_id - assert registered.password_hash is not None + assert registered.principal_id == existing.principal_id + assert registered.password_hash is not None + finally: + await _close(store) @pytest.mark.asyncio async def test_login_verifies_password_and_issues_jwt(tmp_path: Path) -> None: - service = _service(tmp_path) + service, store = await _service(tmp_path) - created = await service.register(email="dev@example.com", password="password-1") - session = await service.login(email="dev@example.com", password="password-1") + try: + created = await service.register(email="dev@example.com", password="password-1") + session = await service.login(email="dev@example.com", password="password-1") - claims = jwt.decode(session.token, "test-secret", algorithms=["HS256"], audience=UI_TOKEN_AUDIENCE) - assert session.principal_id == created.principal_id - assert claims["sub"] == created.principal_id - assert claims["email"] == "dev@example.com" + claims = jwt.decode(session.token, "test-secret", algorithms=["HS256"], audience=UI_TOKEN_AUDIENCE) + assert session.principal_id == created.principal_id + assert claims["sub"] == created.principal_id + assert claims["email"] == "dev@example.com" + finally: + await _close(store) @pytest.mark.asyncio async def test_login_rejects_wrong_password(tmp_path: Path) -> None: - service = _service(tmp_path) + service, store = await _service(tmp_path) - await service.register(email="dev@example.com", password="password-1") + try: + await service.register(email="dev@example.com", password="password-1") - with pytest.raises(ValueError, match="Invalid email or password"): - await service.login(email="dev@example.com", password="wrong-password") + with pytest.raises(ValueError, match="Invalid email or password"): + await service.login(email="dev@example.com", password="wrong-password") + finally: + await _close(store) diff --git a/tests/server/test_identity_bootstrap.py b/tests/server/test_identity_bootstrap.py index 3385ac3..50eba93 100644 --- a/tests/server/test_identity_bootstrap.py +++ b/tests/server/test_identity_bootstrap.py @@ -7,60 +7,68 @@ HostedIdentityBootstrapService, LocalIdentityBootstrapService, ) -from authsome.server.registries import IdentityClaimRegistry, IdentityRegistry +from authsome.server.store import create_server_store from authsome.server.ui_sessions import UiSessionStore @pytest.mark.asyncio async def test_local_bootstrap_registers_without_claim(tmp_path: Path) -> None: identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - registry = IdentityRegistry(tmp_path / "identity_registry.json") - service = LocalIdentityBootstrapService(registry=registry) + store = await create_server_store(home=tmp_path) + service = LocalIdentityBootstrapService(registry=store.identity_registry) - status = await service.register_identity(handle=identity.handle, did=identity.did) + try: + status = await service.register_identity(handle=identity.handle, did=identity.did) - assert status.registration_status == "registered" - assert status.claim_url == "" + assert status.registration_status == "registered" + assert status.claim_url == "" + finally: + await store.close() @pytest.mark.asyncio async def test_hosted_bootstrap_requires_claim_until_identity_is_claimed(tmp_path: Path) -> None: identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - registry = IdentityRegistry(tmp_path / "identity_registry.json") - claims = IdentityClaimRegistry(tmp_path / "claims.json") + store = await create_server_store(home=tmp_path) ui_sessions = UiSessionStore("test-secret") service = HostedIdentityBootstrapService( - registry=registry, - claims=claims, + registry=store.identity_registry, + claims=store.identity_claims, ui_sessions=ui_sessions, server_base_url="http://127.0.0.1:7998", ) - status = await service.register_identity(handle=identity.handle, did=identity.did) + try: + status = await service.register_identity(handle=identity.handle, did=identity.did) - assert status.registration_status == "claim_required" - assert status.claim_url.startswith("http://127.0.0.1:7998/claim/") + assert status.registration_status == "claim_required" + assert status.claim_url.startswith("http://127.0.0.1:7998/claim/") + finally: + await store.close() @pytest.mark.asyncio async def test_hosted_bootstrap_returns_claimed_status_after_claim(tmp_path: Path) -> None: identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - registry = IdentityRegistry(tmp_path / "identity_registry.json") - claims = IdentityClaimRegistry(tmp_path / "claims.json") + store = await create_server_store(home=tmp_path) ui_sessions = UiSessionStore("test-secret") service = HostedIdentityBootstrapService( - registry=registry, - claims=claims, + registry=store.identity_registry, + claims=store.identity_claims, ui_sessions=ui_sessions, server_base_url="http://127.0.0.1:7998", ) - await registry.register(handle=identity.handle, did=identity.did) - await claims.claim_identity(identity.handle, "principal_123") - await claims.accept_claim(identity.handle) + try: + await store.identity_registry.register(handle=identity.handle, did=identity.did) + principal = await store.principals.create_by_email("dev@example.com") + await store.identity_claims.claim_identity(identity.handle, principal.principal_id) + await store.identity_claims.accept_claim(identity.handle) - status = await service.get_identity_status(handle=identity.handle) + status = await service.get_identity_status(handle=identity.handle) - assert status is not None - assert status.registration_status == "claimed" - assert status.principal_id == "principal_123" + assert status is not None + assert status.registration_status == "claimed" + assert status.principal_id == principal.principal_id + finally: + await store.close() diff --git a/tests/server/test_ownership.py b/tests/server/test_ownership.py index 739e420..0de8c89 100644 --- a/tests/server/test_ownership.py +++ b/tests/server/test_ownership.py @@ -9,55 +9,50 @@ HostedOwnershipResolver, LocalOwnershipResolver, ) -from authsome.server.registries import ( - IdentityClaimRegistry, - PrincipalRegistry, - PrincipalVaultBindingRegistry, - VaultRegistry, -) +from authsome.server.store import create_server_store @pytest.mark.asyncio async def test_hosted_resolution_maps_identity_to_default_vault(tmp_path: Path) -> None: - principals = PrincipalRegistry(tmp_path / "principals.json") - claims = IdentityClaimRegistry(tmp_path / "claims.json") - vaults = VaultRegistry(tmp_path / "vaults.json") - bindings = PrincipalVaultBindingRegistry(tmp_path / "bindings.json") - principal = await principals.create_by_email("dev@example.com") - vault = await vaults.create_default() - await bindings.bind_default(principal.principal_id, vault.vault_id) - await claims.claim_identity("steady-wisely-boldly-0042", principal.principal_id) - await claims.accept_claim("steady-wisely-boldly-0042") - - resolver = HostedOwnershipResolver( - principals=principals, - vaults=vaults, - claims=claims, - bindings=bindings, - ) - context = await resolver.resolve(identity="steady-wisely-boldly-0042") - - assert context.principal_id == principal.principal_id - assert context.vault_id == vault.vault_id + store = await create_server_store(home=tmp_path) + try: + principal = await store.principals.create_by_email("dev@example.com") + vault = await store.vaults.create_default() + await store.principal_vault_bindings.bind_default(principal.principal_id, vault.vault_id) + await store.identity_claims.claim_identity("steady-wisely-boldly-0042", principal.principal_id) + await store.identity_claims.accept_claim("steady-wisely-boldly-0042") + + resolver = HostedOwnershipResolver( + principals=store.principals, + vaults=store.vaults, + claims=store.identity_claims, + bindings=store.principal_vault_bindings, + ) + context = await resolver.resolve(identity="steady-wisely-boldly-0042") + + assert context.principal_id == principal.principal_id + assert context.vault_id == vault.vault_id + finally: + await store.close() @pytest.mark.asyncio async def test_local_resolution_creates_implicit_principal_and_vault(tmp_path: Path) -> None: - principals = PrincipalRegistry(tmp_path / "principals.json") - vaults = VaultRegistry(tmp_path / "vaults.json") - bindings = PrincipalVaultBindingRegistry(tmp_path / "bindings.json") - - resolver = LocalOwnershipResolver( - principals=principals, - vaults=vaults, - bindings=bindings, - ) - context = await resolver.resolve(identity="steady-wisely-boldly-0042") - - principal = await principals.get(context.principal_id) - binding = await bindings.get_default_vault(context.principal_id) - - assert principal is not None - assert principal.email == LOCAL_PRINCIPAL_EMAIL - assert binding is not None - assert binding.vault_id == context.vault_id + store = await create_server_store(home=tmp_path) + try: + resolver = LocalOwnershipResolver( + principals=store.principals, + vaults=store.vaults, + bindings=store.principal_vault_bindings, + ) + context = await resolver.resolve(identity="steady-wisely-boldly-0042") + + principal = await store.principals.get(context.principal_id) + binding = await store.principal_vault_bindings.get_default_vault(context.principal_id) + + assert principal is not None + assert principal.email == LOCAL_PRINCIPAL_EMAIL + assert binding is not None + assert binding.vault_id == context.vault_id + finally: + await store.close() diff --git a/tests/vault/test_crypto.py b/tests/vault/test_crypto.py index ec7b7d2..c616787 100644 --- a/tests/vault/test_crypto.py +++ b/tests/vault/test_crypto.py @@ -1,6 +1,6 @@ """Tests for the vault crypto layer. -Only tests our code (MasterSecretResolver, DekManager, AesGcmEncryptionWrapper). +Only tests our code (DekManager, AesGcmEncryptionWrapper). The underlying key-value library's encryption primitives are already well-tested. """ @@ -8,23 +8,19 @@ import base64 import os -import sys from pathlib import Path -from types import ModuleType import pytest from key_value.aio.stores.simple import SimpleStore from authsome.errors import EncryptionUnavailableError +from authsome.server.secrets import MASTER_KEY_ENV, load_master_secret from authsome.vault.crypto import ( _DEK_KEY, _KEY_SIZE, - _MASTER_KEY_ENV, - _MASTER_KEY_FILE_ENV, _META_COLLECTION, AesGcmEncryptionWrapper, DekManager, - MasterSecretResolver, ) # ── Fixtures ────────────────────────────────────────────────────────────────── @@ -44,87 +40,6 @@ def _random_secret() -> str: return base64.b64encode(os.urandom(_KEY_SIZE)).decode("ascii") -def _stub_keyring( - monkeypatch: pytest.MonkeyPatch, - *, - stored: str | None = None, - fail_get: bool = False, - fail_set: bool = False, -) -> dict: - state: dict = {"stored": stored, "set_calls": []} - module = ModuleType("keyring") - - def get_password(service, username): - if fail_get: - raise OSError("keyring unavailable") - return state["stored"] - - def set_password(service, username, value): - if fail_set: - raise OSError("keyring write failed") - state["stored"] = value - state["set_calls"].append(value) - - module.get_password = get_password # type: ignore[attr-defined] - module.set_password = set_password # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, "keyring", module) - return state - - -# ── MasterSecretResolver ────────────────────────────────────────────────────── - - -class TestMasterSecretResolver: - def test_env_var_takes_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(_MASTER_KEY_ENV, "from-env") - _stub_keyring(monkeypatch, stored="from-keyring") - assert MasterSecretResolver(tmp_path).resolve() == "from-env" - - def test_env_var_strips_whitespace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(_MASTER_KEY_ENV, " my-secret ") - assert MasterSecretResolver(tmp_path).resolve() == "my-secret" - - def test_default_file_used_when_env_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(_MASTER_KEY_ENV, raising=False) - _stub_keyring(monkeypatch, fail_get=True) - (tmp_path / "master.key").write_text("from-file", encoding="utf-8") - assert MasterSecretResolver(tmp_path).resolve() == "from-file" - - def test_custom_file_via_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(_MASTER_KEY_ENV, raising=False) - custom = tmp_path / "custom.key" - custom.write_text("from-custom", encoding="utf-8") - monkeypatch.setenv(_MASTER_KEY_FILE_ENV, str(custom)) - _stub_keyring(monkeypatch, fail_get=True) - assert MasterSecretResolver(tmp_path).resolve() == "from-custom" - - def test_keyring_used_when_file_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(_MASTER_KEY_ENV, raising=False) - _stub_keyring(monkeypatch, stored="from-keyring") - assert MasterSecretResolver(tmp_path).resolve() == "from-keyring" - - def test_auto_generates_to_keyring(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(_MASTER_KEY_ENV, raising=False) - state = _stub_keyring(monkeypatch, stored=None) - result = MasterSecretResolver(tmp_path).resolve() - assert result and len(state["set_calls"]) == 1 - assert not (tmp_path / "master.key").exists() - - def test_auto_generates_to_file_when_keyring_unavailable( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.delenv(_MASTER_KEY_ENV, raising=False) - _stub_keyring(monkeypatch, fail_get=True, fail_set=True) - result = MasterSecretResolver(tmp_path).resolve() - assert (tmp_path / "master.key").read_text(encoding="utf-8").strip() == result - - def test_generated_value_is_stable_across_calls(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(_MASTER_KEY_ENV, raising=False) - _stub_keyring(monkeypatch, fail_get=True, fail_set=True) - r = MasterSecretResolver(tmp_path) - assert r.resolve() == r.resolve() - - # ── DekManager ──────────────────────────────────────────────────────────────── @@ -177,9 +92,9 @@ def test_accepts_valid_dek(self, kv: SimpleStore, dek: bytes) -> None: class TestVaultBootstrap: @pytest.mark.asyncio async def test_full_roundtrip(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(_MASTER_KEY_ENV, "my-passphrase") + monkeypatch.setenv(MASTER_KEY_ENV, "my-passphrase") raw_kv = SimpleStore() - secret = MasterSecretResolver(tmp_path).resolve() + secret = load_master_secret(tmp_path) dek = await DekManager().load_or_create(secret, raw_kv) vault_kv = AesGcmEncryptionWrapper(raw_kv, dek=dek) await vault_kv.put("token", {"data": "secret-value"}, collection="creds") @@ -187,9 +102,9 @@ async def test_full_roundtrip(self, tmp_path: Path, monkeypatch: pytest.MonkeyPa @pytest.mark.asyncio async def test_dek_survives_reload(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(_MASTER_KEY_ENV, "stable-passphrase") + monkeypatch.setenv(MASTER_KEY_ENV, "stable-passphrase") raw_kv = SimpleStore() - secret = MasterSecretResolver(tmp_path).resolve() + secret = load_master_secret(tmp_path) dek1 = await DekManager().load_or_create(secret, raw_kv) await AesGcmEncryptionWrapper(raw_kv, dek=dek1).put("k", {"data": "v"}, collection="c") diff --git a/uv.lock b/uv.lock index b2a3f27..e21c325 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/aa/e8a8a75c93dee0ab229df3c2d17f63cd44d0ad5ee8540e2ec42779ce3a39/aioquic-1.2.0-cp38-abi3-win_amd64.whl", hash = "sha256:e3dcfb941004333d477225a6689b55fc7f905af5ee6a556eb5083be0354e653a", size = 1530339, upload-time = "2024-07-06T23:26:34.753Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -110,6 +119,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -124,7 +165,9 @@ name = "authsome" version = "0.4.2" source = { editable = "." } dependencies = [ + { name = "aiosqlite" }, { name = "argon2-cffi" }, + { name = "asyncpg" }, { name = "base58" }, { name = "browser-cookie3" }, { name = "click" }, @@ -146,6 +189,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "httpx" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -155,7 +199,9 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20" }, { name = "argon2-cffi", specifier = ">=25.1.0" }, + { name = "asyncpg", specifier = ">=0.30" }, { name = "base58", specifier = ">=2.1.1" }, { name = "browser-cookie3", specifier = ">=0.19" }, { name = "click", specifier = ">=8.0" }, @@ -167,6 +213,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7" }, { name = "mitmproxy", specifier = ">=11.0" }, { name = "posthog", specifier = ">=3.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.6.0" }, { name = "py-key-value-aio", extras = ["disk"] }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyjwt", specifier = ">=2.12.1" }, @@ -383,6 +430,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -592,6 +648,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -617,6 +682,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -702,6 +776,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -1041,6 +1124,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1059,6 +1151,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1083,6 +1184,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/4a/7b50a53e9a557c7e78deb36811eb6b0d05f40f20c3b633e379e206426e37/posthog-7.15.0-py3-none-any.whl", hash = "sha256:0ada8fe94c7fb9b7ad507180eed308fea3b45063ddb4088cc8eaddb9cdea15c4", size = 248461, upload-time = "2026-05-19T12:57:01.575Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "publicsuffix2" version = "2.20191221" @@ -1360,6 +1477,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + [[package]] name = "python-multipart" version = "0.0.29" @@ -1391,6 +1521,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1598,6 +1764,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + [[package]] name = "wcwidth" version = "0.7.0"