diff --git a/docs/en/api/08-admin.md b/docs/en/api/08-admin.md index 94d0109be8..182439a57e 100644 --- a/docs/en/api/08-admin.md +++ b/docs/en/api/08-admin.md @@ -956,7 +956,7 @@ Create a new workspace with its first admin user. | settings | object | No | `null` | Initial Account runtime configuration. ROOT-only; supports `feishu`, `github`, `acl`, `agent_evolution`, `vlm`, `query_planner`, `embedding`, and `vectordb`. `vectordb` and Embedding create-only fields can only be set here | **Notes:** -- File-backed account creation rejects an `account_id` over 255 UTF-8 bytes with `INVALID_ARGUMENT` before writing the account registry. Trusted auto-registration rejects such new accounts on the request, so they cannot block other identities from registering. Existing accounts and users remain supported; user and peer ID rules are unchanged. +- File-backed account creation rejects an `account_id` over 255 UTF-8 bytes with `INVALID_ARGUMENT` before writing the account registry. Newly persisted user IDs are limited to 255 characters across account-store providers. Trusted mode rejects an oversized new user or a new oversized account before enqueueing auto-registration, so invalid identities cannot block other identities from registering. Existing oversized File users and accounts remain readable. - In `trusted` mode, only identity and role are created: no personal key, hash, or key index is generated. The response omits `user_key`, and `seed` is unused. - Omit `seed` for the default random API key. Set `OV_KEY_SEED` to a securely generated secret before running a deterministic example; the Go example also requires `import "os"`. Short or predictable seeds make the key guessable. - `settings` receives structural and effective Embedding/VectorDB pair validation before the Account and its directories are created. A failed validation leaves no Account, user, or configuration file. diff --git a/docs/en/guides/01-configuration.md b/docs/en/guides/01-configuration.md index d6bd298618..7463545071 100644 --- a/docs/en/guides/01-configuration.md +++ b/docs/en/guides/01-configuration.md @@ -1898,7 +1898,7 @@ When running OpenViking as an HTTP service, add a `server` section to `ov.conf`: | `port` | int | Bind port | `1933` | | `auth_mode` | str / null | Built-in modes: `"dev"`, `"api_key"`, `"trusted"`, `"oidc"`, `"ldap"`. When omitted/null, infer `api_key` from a non-empty `root_api_key`; otherwise infer `dev`. | `null` | | `root_api_key` | str | Root API key for multi-tenant auth in `api_key` mode. In `trusted` mode it is required on every deployment, including localhost; it does not become the source of user identity | `null` | -| `account_store.provider` | str | Complete Account store provider. The built-in `"file"` provider owns account, user, group, and API-key records while preserving the existing AGFS JSON registry and process-local lookup indexes. | `"file"` | +| `account_store.provider` | str | Complete Account store provider. Built-in values are `"file"`, `"mysql"`, and `"redis_mysql"`; it owns account, user, group, and API-key records. `"file"` preserves the existing AGFS JSON registry and owns its process-local lookup indexes. `"redis_mysql"` keeps MySQL authoritative and adds a shared Redis cache for authentication and request identity reads. | `"file"` | | `account_store.params` | object | Provider-specific startup parameters. Framework-owned filesystem and File-provider API-key hashing dependencies cannot be overridden here. | `{}` | | `profile_enabled` | bool | Whether to allow request-scoped cProfile via `profile=1` on HTTP requests. When disabled, the server ignores that query parameter. When enabled, the CLI can display the returned `profile`, while the Python HTTP client currently triggers profiling but does not automatically attach the top-level `profile` field to most SDK return values. | `false` | | `cors_origins` | list | Allowed CORS origins | `["*"]` | @@ -1913,6 +1913,111 @@ When running OpenViking as an HTTP service, add a `server` section to `ov.conf`: | `user_config_defaults.auto_commit_policy` | object | Deployment default auto-commit policy for newly created sessions without an explicit policy. | `null` | | `agent_evolution.enabled` | bool | Startup cluster default for Agent Evolution. Account and Cluster Admin settings may override it at runtime. When enabled, session commits may generate or update cases, trajectories, and experiences according to the session `memory_policy`. Existing memories remain readable and searchable when disabled. | `false` | +The MySQL provider stores a versioned SHA-256 digest of each high-entropy API +key by default and uses a separate digest index for constant-cost lookup. Set +`credential_storage` to `"encrypted"` to retain keys in AES-GCM encrypted form +instead; this requires `credential_encryption_key`, a base64-encoded 32-byte +key. MySQL never persists API-key plaintext. Hash mode exposes only key prefixes +in user listings, while encrypted mode can decrypt the key for the same listing +workflow. `credential_storage` controls the format of newly written credentials; +existing rows are decoded from their `material` prefix. When migrating from +encrypted storage to hash storage, retain `credential_encryption_key` until all +encrypted credentials have been rotated. Replacing that encryption key directly +is not supported because `aesgcm:v1` records do not carry a key identifier. + +Install the MySQL extra and configure the top-level `service_name`. Its value is +stored in the `service_name` column on every account-store table and is the +deployment boundary when multiple OpenViking clusters share a database. Changing +`service_name` selects a different logical account-store partition. + +The provider never creates or migrates database tables. Create the normalized +tables in `openviking/server/account_stores/sql/init.sql` through your normal +DBA or deployment process before starting the server. Future reviewed schema +changes are stored as ordered SQL files in +`openviking/server/account_stores/sql/changes/`. The five `ov_accounts*` table +names are fixed; use distinct top-level `service_name` values rather than +separate table prefixes to isolate OpenViking clusters that share a database. + +```json +{ + "service_name": "production", + "server": { + "account_store": { + "provider": "mysql", + "params": { + "host": "db.example.com", + "user": "openviking", + "password": "secret", + "database": "openviking", + "credential_storage": "encrypted", + "credential_encryption_key": "base64-encoded-32-byte-key" + } + } + } +} +``` + +`redis_mysql` requires the `redis-mysql` extra and wraps the same MySQL +parameters. Its `cache` block follows the top-level `cache` `provider/params` +shape. The Python client currently supports `standalone`, `cluster`, and +`sentinel` Redis deployments. It caches API-key bindings, users, per-user group +IDs, and deletion records for a fixed 60 seconds by default. Cache fills and account mutations +share an account-scoped Redis lease; normal cache hits do not acquire that +lease. Mutations commit to MySQL first, then invalidate affected cache entries. +If Redis is unavailable, the MySQL mutation still succeeds and stale cache +entries remain valid only until their fixed TTL expires. Cache fills also +maintain an +account-scoped, expiry-scored key index. Each indexed cache fill removes expired +index members, allowing account deletion or recreation to clear only that +account's current cached users, groups, deletion records, and credential data. +API-key bindings are checked against account and user credential fences, so +normal key replacement, revocation, and user deletion invalidate old bindings. +Redis automatically removes the index when its last sorted-set member is removed. +Cleanup does not issue a separate index deletion, which could remove a newer +fill's index after lease loss. Provisioning +and key replacement clear negative lookups both with and without a user hint. +Redis connection and command timeouts are configured with +`cache.params.connect_timeout_ms` and `cache.params.command_timeout_ms`; both +default to 5000 milliseconds when omitted. These are socket timeouts, not an +overall request deadline. + +```json +{ + "server": { + "account_store": { + "provider": "redis_mysql", + "params": { + "mysql": { + "host": "db.example.com", + "user": "openviking", + "password": "secret", + "database": "openviking" + }, + "cache": { + "provider": "redis", + "params": { + "mode": "sentinel", + "endpoints": [ + "rediss://sentinel-1.example.com:26379", + "rediss://sentinel-2.example.com:26379", + "rediss://sentinel-3.example.com:26379" + ], + "master_name": "openviking", + "username": "openviking", + "password_env": "OPENVIKING_REDIS_PASSWORD", + "sentinel_password_env": "OPENVIKING_SENTINEL_PASSWORD", + "key_prefix": "ov:account-auth", + "ttl_seconds": 60, + "lock_ttl_seconds": 60, + "lock_wait_seconds": 30 + } + } + } + } + } +} +``` + Omitting `auth_mode` (or setting it to `null`) selects `api_key` when a non-empty `root_api_key` is configured, and `dev` otherwise. `dev` is allowed only on localhost and accepts requests without authentication. An empty-string `root_api_key` is invalid. Explicit `auth_mode: "api_key"` requires a non-empty `root_api_key`, including on localhost; without it, startup fails instead of falling back to development mode. Use the root key with the Admin API to create accounts and user/admin keys; use those tenant-bound keys for data access. `trusted` mode accepts account/user identity headers from a trusted gateway and does not require user-key provisioning first. Its root key is required for startup and every authenticated request, including on localhost. For role resolution, OIDC/LDAP setup, and gateway requirements, see [Authentication](04-authentication.md). diff --git a/docs/zh/api/08-admin.md b/docs/zh/api/08-admin.md index b554f78bf6..b0e78ab3a9 100644 --- a/docs/zh/api/08-admin.md +++ b/docs/zh/api/08-admin.md @@ -827,7 +827,7 @@ User override 并重新继承上述默认值,请 PATCH `{"memory_policy": null | settings | object | 否 | `null` | Account 运行时初始配置。仅 ROOT 可提交;支持 `feishu`、`github`、`acl`、`agent_evolution`、`vlm`、`query_planner`、`embedding` 和 `vectordb`。其中 `vectordb` 及 Embedding 创建期字段只能在这里设置 | **说明:** -- 文件存储账户创建时,超过 255 个 UTF-8 字节的 `account_id` 会在写入账户注册表前返回 `INVALID_ARGUMENT`。trusted 自动注册会在请求时拒绝这类新账户,避免阻塞其他身份的注册。既有账户和用户仍可使用,user ID 和 peer ID 规则保持不变。 +- 文件存储账户创建时,超过 255 个 UTF-8 字节的 `account_id` 会在写入账户注册表前返回 `INVALID_ARGUMENT`。新持久化的 user ID 在所有账户存储 Provider 中最多 255 个字符。trusted 模式会在加入自动注册队列前拒绝超长的新 user ID 和新建的超长账户,避免无效身份阻塞其他身份注册;已有的超长 File user 和 account 仍可读取。 - 在 `trusted` 模式下,只创建身份和角色,不生成个人密钥、密钥哈希或密钥索引;响应不包含 `user_key`,`seed` 不参与处理。 - 省略 `seed` 时随机生成 API Key。运行确定性示例前,将 `OV_KEY_SEED` 设为安全生成的秘密值;Go 示例还需导入 `os`。不要使用短字符串或可预测内容作为 seed。 - `settings` 在账号和目录创建前完成结构与有效 Embedding/VectorDB 联合校验。校验失败不会留下 Account、用户或配置文件。 diff --git a/docs/zh/guides/01-configuration.md b/docs/zh/guides/01-configuration.md index 8f06b0be98..a546685082 100644 --- a/docs/zh/guides/01-configuration.md +++ b/docs/zh/guides/01-configuration.md @@ -1885,7 +1885,7 @@ ov add-resource ./docs --exclude "*.tmp" | `port` | int | 绑定端口 | `1933` | | `auth_mode` | str / null | 内置模式:`"dev"`、`"api_key"`、`"trusted"`、`"oidc"`、`"ldap"`。省略或设为 null 时,有非空 `root_api_key` 则推导为 `api_key`,否则为 `dev`。 | `null` | | `root_api_key` | str | `api_key` 模式必填的 Root API Key;`trusted` 模式在所有部署中均必填,包括 localhost,不负责解析普通用户身份 | `null` | -| `account_store.provider` | str | 完整 Account Store Provider。内置 `"file"` Provider,统一持久化 Account、User、Group 和 API Key,兼容现有 AGFS JSON 注册表并维护进程内查询索引。 | `"file"` | +| `account_store.provider` | str | 完整 Account Store Provider。内置 `"file"`、`"mysql"` 和 `"redis_mysql"`;统一持久化 Account、User、Group 和 API Key。`"file"` 兼容现有 AGFS JSON 注册表,并由 Provider 自身维护进程内查询索引。`"redis_mysql"` 保持 MySQL 为权威存储,并增加共享 Redis 认证和请求身份读取缓存。 | `"file"` | | `account_store.params` | object | Provider 启动参数。不能通过这里覆盖框架注入的 VikingFS 和 File Provider API Key Hash 配置。 | `{}` | | `profile_enabled` | bool | 是否允许 HTTP 请求通过 `profile=1` 开启请求级 cProfile。关闭时服务端会忽略该请求参数;开启后,CLI 可以显示返回的 `profile`,而 Python HTTP client 默认只触发服务端 profile,不会把顶层 `profile` 字段自动附着到大多数 SDK 返回值上。 | `false` | | `cors_origins` | list | CORS 允许的来源 | `["*"]` | @@ -1900,6 +1900,97 @@ ov add-resource ./docs --exclude "*.tmp" | `user_config_defaults.auto_commit_policy` | object | 新建 Session 未显式指定策略时使用的部署级自动 Commit 默认策略。 | `null` | | `agent_evolution.enabled` | bool | Agent 进化的集群启动默认值,运行时可由 Account 或 Cluster Admin settings 覆盖。开启时,session commit 可按 session `memory_policy` 生成或更新 cases、trajectories 和 experiences;关闭后已有记忆仍可读取和检索。 | `false` | +MySQL Provider 默认保存高熵 API Key 的带版本 SHA-256 摘要,并通过独立摘要索引 +进行固定成本查询。将 `credential_storage` 设为 `"encrypted"` 可改用 AES-GCM +可逆加密;此时必须提供 `credential_encryption_key`,其值为 base64 编码的 +32 字节密钥。MySQL 不会持久化 API Key 原文。哈希模式的用户列表只展示 key +prefix;加密模式可为同一列表工作流解密展示 key。`credential_storage` 仅决定 +新写入凭证的格式,读取时根据每条记录的 `material` 前缀选择解码方式。从加密模式 +迁移到哈希模式时,应保留 `credential_encryption_key`,直到所有旧加密凭证完成 +轮换。`aesgcm:v1` 记录不包含 key id,因此不支持直接替换该加密密钥。 + +安装 MySQL extra 后,通过顶层 `service_name` 配置部署名称。该值会写入每张 +Account Store 表的 `service_name` 列,并在多个 OpenViking 集群共用一个数据库时 +充当隔离边界。修改 `service_name` 会选择另一组逻辑 Account Store 分区。 + +Provider 不会创建或迁移数据库表。启动服务前,应通过现有 DBA 或部署流程手工创建 +`openviking/server/account_stores/sql/init.sql` 中定义的规范化表。后续经评审的 +schema 变更按顺序存放于 `openviking/server/account_stores/sql/changes/`。 +五张 `ov_accounts*` 表的名称固定;多个 OpenViking 集群共用数据库时,通过不同的 +顶层 `service_name` 隔离部署,不使用不同表前缀。 + +```json +{ + "service_name": "production", + "server": { + "account_store": { + "provider": "mysql", + "params": { + "host": "db.example.com", + "user": "openviking", + "password": "secret", + "database": "openviking", + "credential_storage": "encrypted", + "credential_encryption_key": "base64-encoded-32-byte-key" + } + } + } +} +``` + +`redis_mysql` 需要安装 `redis-mysql` extra,并在参数中嵌套同一份 MySQL 配置。 +其 `cache` 配置沿用顶层 `cache` 的 `provider/params` 结构,目前 Python +客户端支持 `standalone`、`cluster` 和 `sentinel` 三种 Redis 部署模式。 +它默认以固定 60 秒 TTL 缓存 API Key 绑定、User、每个 User 的 Group ID 和删除记录。 +缓存重建与同一 Account 的修改共用 Redis 租约,正常缓存命中不获取该租约。修改会先 +提交 MySQL,再失效相关缓存。Redis 不可用时 MySQL 修改仍会成功,遗留缓存最多保持到 +固定 TTL 到期。缓存回填同时维护 Account +级、按过期时间排序的 key 索引;每次写入已索引缓存时会清理过期成员,因此删除或重建 +Account 时只会清理该 Account 当前的 User、Group、删除记录和凭据缓存。API Key 绑定会 +校验 Account 和 User 的凭据 fence,所以正常的换 Key、吊销和删 User 会使旧绑定失效。 +索引的最后一个有序集合成员被移除后,Redis 会自动删除空索引;清理结束时不再单独删除 +索引,避免失去租约的旧请求误删新回填的索引。创建身份或换 Key 会同时清除携带和未携带 +User 提示的负缓存。 +Redis 连接和命令超时通过 `cache.params.connect_timeout_ms` 和 +`cache.params.command_timeout_ms` 配置,未配置时均为 5000 毫秒;这不是整个请求的总超时。 + +```json +{ + "server": { + "account_store": { + "provider": "redis_mysql", + "params": { + "mysql": { + "host": "db.example.com", + "user": "openviking", + "password": "secret", + "database": "openviking" + }, + "cache": { + "provider": "redis", + "params": { + "mode": "sentinel", + "endpoints": [ + "rediss://sentinel-1.example.com:26379", + "rediss://sentinel-2.example.com:26379", + "rediss://sentinel-3.example.com:26379" + ], + "master_name": "openviking", + "username": "openviking", + "password_env": "OPENVIKING_REDIS_PASSWORD", + "sentinel_password_env": "OPENVIKING_SENTINEL_PASSWORD", + "key_prefix": "ov:account-auth", + "ttl_seconds": 60, + "lock_ttl_seconds": 60, + "lock_wait_seconds": 30 + } + } + } + } + } +} +``` + 省略 `auth_mode`(或设为 `null`)时,配置了非空 `root_api_key` 则选择 `api_key`,否则选择 `dev`。`dev` 仅允许监听 localhost,不进行身份认证。`root_api_key` 不能配置为空字符串。 显式设置 `auth_mode: "api_key"` 时,包括 localhost 在内都必须提供非空 `root_api_key`;缺少该 key 会导致启动失败,不会回退到开发模式。使用 root key 调用 Admin API 创建 account 和 user/admin key,数据访问使用这些绑定租户身份的 key。`trusted` 模式接受可信网关注入的 account/user 身份头,无需预先创建 user key;其 root key 在启动和每个认证请求中均必填,包括 localhost。角色解析、OIDC/LDAP 配置与网关要求参见 [身份认证](04-authentication.md)。 diff --git a/openviking/core/identifiers.py b/openviking/core/identifiers.py index 85d02bf1b9..5d6fb3b0d1 100644 --- a/openviking/core/identifiers.py +++ b/openviking/core/identifiers.py @@ -6,6 +6,7 @@ from typing import Optional _VALIDATION_PATTERN = re.compile(r"^[a-zA-Z0-9_.@-]+$") +MAX_IDENTIFIER_LENGTH = 255 def validate_identifier_part(part: str, part_name: str) -> str | None: @@ -51,3 +52,10 @@ def validate_account_id(account_id: str) -> str | None: def validate_user_id(user_id: str) -> str | None: """Validate a user id.""" return validate_identifier_part(user_id, "user_id") + + +def validate_identifier_length(value: str, name: str) -> str | None: + """Validate the persistence length of a new account-store identifier.""" + if len(value) > MAX_IDENTIFIER_LENGTH: + return f"{name} must not exceed {MAX_IDENTIFIER_LENGTH} characters." + return None diff --git a/openviking/server/account_stores/mysql.py b/openviking/server/account_stores/mysql.py new file mode 100644 index 0000000000..d3507e617b --- /dev/null +++ b/openviking/server/account_stores/mysql.py @@ -0,0 +1,1296 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""SQLAlchemy-backed MySQL implementation of the account and API-key store.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import secrets +import ssl as ssl_module +from contextlib import asynccontextmanager +from typing import AsyncGenerator +from uuid import uuid4 + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from sqlalchemy import and_, case, delete, func, select, update +from sqlalchemy.engine import URL +from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from openviking.core.identifiers import MAX_IDENTIFIER_LENGTH +from openviking.server.account_stores.base import AccountStore +from openviking.server.account_stores.models import ( + AccountSummary, + DeletionRecord, + GroupSummary, + UsersPage, + UserSummary, +) +from openviking.server.account_stores.mysql_schema import ( + Account, + Credential, + Group, + GroupMember, + User, +) +from openviking.server.identity import Role +from openviking_cli.exceptions import ( + AlreadyExistsError, + FailedPreconditionError, + InvalidArgumentError, + NotFoundError, + PermissionDeniedError, +) +from openviking_cli.session.user_id import ( + validate_account_id, + validate_identifier_part, + validate_user_id, +) +from openviking_cli.utils.config.config_utils import normalize_service_name + +_MYSQL_IDENTIFIER_MAX_LENGTH = MAX_IDENTIFIER_LENGTH +_RETAINED_REVOKED_CREDENTIALS = 10 + + +def _validate_length(value: str, name: str) -> None: + if len(value) > _MYSQL_IDENTIFIER_MAX_LENGTH: + raise InvalidArgumentError( + f"{name} must not exceed {_MYSQL_IDENTIFIER_MAX_LENGTH} characters." + ) + + +def _validate(value: str, validator, name: str) -> None: + if error := validator(value): + raise InvalidArgumentError(error) + _validate_length(value, name) + + +def _validate_role(role: str) -> Role: + if role == Role.ROOT: + raise PermissionDeniedError("Account users cannot be assigned ROOT") + if role not in (Role.ADMIN, Role.USER): + raise InvalidArgumentError("Account user role must be user or admin.") + return Role(role) + + +def _like_pattern(pattern: str) -> str: + escaped = [] + for character in pattern: + if character == "*": + escaped.append("%") + elif character == "?": + escaped.append("_") + elif character in {"%", "_", "="}: + escaped.append("=" + character) + else: + escaped.append(character) + return "".join(escaped) + + +def _substring_pattern(value: str) -> str: + escaped = "".join( + "=" + character if character in {"%", "_", "="} else character for character in value + ) + return f"%{escaped}%" + + +def _credential_digest(api_key: str) -> str: + """Return the lookup digest for a high-entropy API key.""" + return hashlib.sha256(api_key.encode()).hexdigest() + + +_DIGEST_CREDENTIAL_PREFIX = "sha256:v1:" +_ENCRYPTED_CREDENTIAL_PREFIX = "aesgcm:v1:" + + +def _deletion(record) -> DeletionRecord | None: + if record.deletion_task_id is None: + return None + return DeletionRecord( + task_id=record.deletion_task_id, + owner_account_id=record.deletion_owner_account_id, + owner_user_id=record.deletion_owner_user_id, + ) + + +class MySQLAccountStore(AccountStore): + """A manually provisioned relational account store scoped by service name.""" + + def __init__( + self, + *, + service_name: str, + params: dict[str, object] | None = None, + ) -> None: + values = dict(params or {}) + try: + self._service_name = normalize_service_name(service_name) + except ValueError as exc: + raise InvalidArgumentError(str(exc)) from None + _validate_length(self._service_name, "service_name") + + credential_storage = values.pop("credential_storage", "hash") + encryption_key = values.pop("credential_encryption_key", None) + self._credential_storage = self._validate_credential_storage(credential_storage) + self._credential_cipher = self._new_credential_cipher(encryption_key) + if self._credential_storage == "encrypted" and self._credential_cipher is None: + raise InvalidArgumentError( + "encrypted MySQL credential storage requires credential_encryption_key" + ) + self._url, engine_options = self._connection_options(values) + self._engine_options = engine_options + self._engine: AsyncEngine | None = None + self._sessions: async_sessionmaker[AsyncSession] | None = None + + @staticmethod + def _validate_credential_storage(mode: object) -> str: + if mode not in {"hash", "encrypted"}: + raise InvalidArgumentError("credential_storage must be 'hash' or 'encrypted'") + return str(mode) + + @staticmethod + def _new_credential_cipher(encryption_key: object) -> AESGCM | None: + if encryption_key is None: + return None + if not isinstance(encryption_key, str): + raise InvalidArgumentError("credential_encryption_key must be base64-encoded") + try: + key = base64.b64decode(encryption_key.encode(), altchars=b"-_", validate=True) + except (binascii.Error, ValueError, UnicodeError) as exc: + raise InvalidArgumentError("credential_encryption_key must be base64-encoded") from exc + if len(key) != 32: + raise InvalidArgumentError("credential_encryption_key must decode to exactly 32 bytes") + return AESGCM(key) + + @staticmethod + def _credential_associated_data(account_id: str, user_id: str) -> bytes: + return f"{account_id}\0{user_id}".encode() + + def _encode_credential(self, account_id: str, user_id: str, api_key: str) -> str: + if self._credential_storage == "hash": + return _DIGEST_CREDENTIAL_PREFIX + _credential_digest(api_key) + assert self._credential_cipher is not None + nonce = secrets.token_bytes(12) + ciphertext = self._credential_cipher.encrypt( + nonce, + api_key.encode(), + self._credential_associated_data(account_id, user_id), + ) + return _ENCRYPTED_CREDENTIAL_PREFIX + base64.urlsafe_b64encode(nonce + ciphertext).decode() + + def _decrypt_credential(self, account_id: str, user_id: str, material: str) -> str | None: + if self._credential_cipher is None or not material.startswith(_ENCRYPTED_CREDENTIAL_PREFIX): + return None + try: + payload = base64.urlsafe_b64decode( + material[len(_ENCRYPTED_CREDENTIAL_PREFIX) :].encode() + ) + return self._credential_cipher.decrypt( + payload[:12], + payload[12:], + self._credential_associated_data(account_id, user_id), + ).decode() + except (InvalidTag, ValueError, UnicodeError): + return None + + def _matches_credential( + self, account_id: str, user_id: str, material: str, api_key: str + ) -> bool: + if material.startswith(_DIGEST_CREDENTIAL_PREFIX): + expected = _DIGEST_CREDENTIAL_PREFIX + _credential_digest(api_key) + return hmac.compare_digest(material, expected) + plaintext = self._decrypt_credential(account_id, user_id, material) + return plaintext is not None and hmac.compare_digest(plaintext, api_key) + + def _new_credential( + self, + account_id: str, + user_id: str, + user_pk: int, + api_key: str, + *, + sequence: int, + ): + return Credential( + credential_id=uuid4().hex, + service_name=self._service_name, + user_pk=user_pk, + active_user_pk=user_pk, + material=self._encode_credential(account_id, user_id, api_key), + lookup_digest=_credential_digest(api_key), + prefix=api_key[:8], + sequence=sequence, + ) + + @staticmethod + def _connection_options( + values: dict[str, object], + ) -> tuple[URL, dict[str, object]]: + allowed = { + "user", + "password", + "database", + "host", + "port", + "minsize", + "maxsize", + "connect_timeout", + "ssl", + "unix_socket", + } + unknown = values.keys() - allowed + if unknown: + raise InvalidArgumentError( + f"Unknown MySQL account store parameters: {', '.join(sorted(unknown))}" + ) + missing = [name for name in ("user", "password", "database") if name not in values] + if missing: + raise InvalidArgumentError(f"MySQL account store requires: {', '.join(missing)}") + + connect_args: dict[str, object] = { + "connect_timeout": values.get("connect_timeout", 10), + } + if socket := values.get("unix_socket"): + connect_args["unix_socket"] = socket + ssl = values.get("ssl") + if isinstance(ssl, dict): + unknown_ssl = ssl.keys() - {"ca", "cert", "key", "check_hostname"} + if unknown_ssl: + raise InvalidArgumentError(f"Unknown MySQL TLS options: {sorted(unknown_ssl)}") + context = ssl_module.create_default_context(cafile=ssl.get("ca")) + context.check_hostname = bool(ssl.get("check_hostname", True)) + if ssl.get("cert"): + context.load_cert_chain(ssl["cert"], ssl.get("key")) + connect_args["ssl"] = context + elif ssl is True: + connect_args["ssl"] = ssl_module.create_default_context() + elif ssl not in (None, False): + raise InvalidArgumentError("ssl must be a boolean or TLS options mapping") + + url = URL.create( + "mysql+aiomysql", + username=str(values["user"]), + password=str(values["password"]), + host=str(values.get("host", "127.0.0.1")), + port=int(values.get("port", 3306)), + database=str(values["database"]), + query={"charset": "utf8mb4"}, + ) + return url, { + "pool_size": int(values.get("maxsize", 10)), + "max_overflow": 0, + "pool_pre_ping": True, + "pool_recycle": 3600, + "connect_args": connect_args, + "isolation_level": "READ COMMITTED", + } + + async def load(self) -> None: + if self._engine is not None: + return + try: + engine = create_async_engine(self._url, **self._engine_options) + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as session: + for model in (Account, User, Group, GroupMember, Credential): + await session.execute(select(*model.__table__.columns).limit(1)) + except ImportError: + raise RuntimeError("MySQL account storage requires openviking[mysql]") from None + except DBAPIError as exc: + await engine.dispose() + original_args = getattr(exc.orig, "args", ()) + error_code = original_args[0] if original_args else None + message = str(exc).lower() + if error_code in {1054, 1146} or any( + marker in message + for marker in ("unknown column", "doesn't exist", "does not exist") + ): + raise RuntimeError( + "MySQL account schema is missing or incompatible; create " + "all configured relational tables before starting" + ) from exc + raise + except BaseException: + if "engine" in locals(): + await engine.dispose() + raise + self._engine = engine + self._sessions = sessions + + async def close(self) -> None: + if self._engine is not None: + await self._engine.dispose() + self._engine = None + self._sessions = None + + @property + def _session_factory(self) -> async_sessionmaker[AsyncSession]: + assert self._sessions is not None + return self._sessions + + @asynccontextmanager + async def _transaction(self) -> AsyncGenerator[AsyncSession, None]: + async with self._session_factory() as session: + async with session.begin(): + yield session + + async def _lock_account(self, session: AsyncSession, account_id: str): + account = await session.scalar( + select(Account) + .where( + Account.service_name == self._service_name, + Account.account_id == account_id, + ) + .with_for_update() + ) + if account is None: + raise NotFoundError(account_id, "account") + return account + + async def _lock_user(self, session: AsyncSession, account, user_id: str): + user = await session.scalar( + select(User) + .where( + User.service_name == self._service_name, + User.account_pk == account.account_pk, + User.user_id == user_id, + ) + .with_for_update() + ) + if user is None: + raise NotFoundError(user_id, "user") + return user + + @staticmethod + def _require_active(record) -> None: + deletion = _deletion(record) + if deletion: + raise FailedPreconditionError( + "Identity deletion is in progress", details={"task_id": deletion["task_id"]} + ) + + async def _protect_last_admin(self, session: AsyncSession, account, user) -> None: + if user.role != Role.ADMIN or user.deletion_task_id: + return + count = await session.scalar( + select(func.count()) + .select_from(User) + .where( + User.service_name == self._service_name, + User.account_pk == account.account_pk, + User.role == Role.ADMIN, + User.deletion_task_id.is_(None), + ) + ) + if count <= 1: + raise FailedPreconditionError("Cannot remove the last active account admin") + + @staticmethod + def _account_summary(account, user_count: int) -> AccountSummary: + return { + "account_id": account.account_id, + "created_at": account.created_at.isoformat(), + "user_count": user_count, + "status": "deleting" if account.deletion_task_id else "active", + **({"task_id": account.deletion_task_id} if account.deletion_task_id else {}), + } + + async def get_account(self, account_id: str) -> AccountSummary | None: + async with self._session_factory() as session: + row = ( + await session.execute( + select(Account, func.count(User.user_pk)) + .outerjoin( + User, + and_( + User.service_name == Account.service_name, + User.account_pk == Account.account_pk, + ), + ) + .where( + Account.service_name == self._service_name, + Account.account_id == account_id, + ) + .group_by(Account.account_pk) + ) + ).one_or_none() + return self._account_summary(*row) if row else None + + async def get_user(self, account_id: str, user_id: str) -> UserSummary | None: + async with self._session_factory() as session: + user = await session.scalar( + select(User) + .join( + Account, + and_( + Account.service_name == User.service_name, + Account.account_pk == User.account_pk, + ), + ) + .where( + User.service_name == self._service_name, + Account.account_id == account_id, + User.user_id == user_id, + ) + ) + return UserSummary(user_id=user.user_id, role=user.role) if user else None + + async def create_account( + self, + account_id: str, + admin_user_id: str, + ) -> None: + _validate(account_id, validate_account_id, "account_id") + _validate(admin_user_id, validate_user_id, "admin_user_id") + try: + async with self._transaction() as session: + account = Account(service_name=self._service_name, account_id=account_id) + session.add(account) + await session.flush() + session.add( + User( + service_name=self._service_name, + account_pk=account.account_pk, + user_id=admin_user_id, + role=Role.ADMIN, + ) + ) + await session.flush() + except IntegrityError as exc: + raise AlreadyExistsError(account_id, "account") from exc + + async def create_account_with_api_key( + self, + account_id: str, + admin_user_id: str, + api_key: str, + ) -> None: + _validate(account_id, validate_account_id, "account_id") + _validate(admin_user_id, validate_user_id, "admin_user_id") + try: + async with self._transaction() as session: + account = Account(service_name=self._service_name, account_id=account_id) + session.add(account) + await session.flush() + user = User( + service_name=self._service_name, + account_pk=account.account_pk, + user_id=admin_user_id, + role=Role.ADMIN, + ) + session.add(user) + await session.flush() + session.add( + self._new_credential( + account_id, + admin_user_id, + user.user_pk, + api_key, + sequence=1, + ) + ) + except IntegrityError as exc: + raise AlreadyExistsError(account_id, "account") from exc + + async def create_user(self, account_id: str, user_id: str, role: str) -> None: + _validate(user_id, validate_user_id, "user_id") + role = _validate_role(role) + try: + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + session.add( + User( + service_name=self._service_name, + account_pk=account.account_pk, + user_id=user_id, + role=role, + ) + ) + await session.flush() + except IntegrityError as exc: + raise AlreadyExistsError(user_id, "user") from exc + + async def create_user_with_api_key( + self, + account_id: str, + user_id: str, + role: str, + api_key: str, + ) -> None: + _validate(user_id, validate_user_id, "user_id") + role = _validate_role(role) + try: + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + user = User( + service_name=self._service_name, + account_pk=account.account_pk, + user_id=user_id, + role=role, + ) + session.add(user) + await session.flush() + session.add( + self._new_credential( + account_id, + user_id, + user.user_pk, + api_key, + sequence=1, + ) + ) + except IntegrityError as exc: + raise AlreadyExistsError(user_id, "user") from exc + + async def delete_account(self, account_id: str) -> None: + async with self._transaction() as session: + await session.delete(await self._lock_account(session, account_id)) + + async def list_accounts( + self, + name_filter: str | None = None, + limit: int | None = None, + page: int = 1, + query_filter: str | None = None, + ) -> list[AccountSummary]: + predicates = [Account.service_name == self._service_name] + if name_filter: + predicates.append(Account.account_id.like(_like_pattern(name_filter), escape="=")) + if query := (query_filter or "").strip(): + predicates.append( + func.lower(Account.account_id).like( + _substring_pattern(query.casefold()), + escape="=", + ) + ) + statement = ( + select(Account, func.count(User.user_pk)) + .outerjoin( + User, + and_( + User.service_name == Account.service_name, + User.account_pk == Account.account_pk, + ), + ) + .where(*predicates) + .group_by(Account.account_pk) + .order_by(Account.created_at, Account.account_id) + ) + if limit is not None: + statement = statement.limit(limit).offset((max(1, page) - 1) * limit) + async with self._session_factory() as session: + rows = (await session.execute(statement)).all() + return [self._account_summary(account, count) for account, count in rows] + + async def list_users_page( + self, + account_id: str, + limit: int | None = 100, + name_filter: str | None = None, + role_filter: str | None = None, + page: int = 1, + query_filter: str | None = None, + fresh: bool = False, + ) -> UsersPage: + del fresh + async with self._session_factory() as session: + # One HTTP response must derive its rows and summary from one MVCC snapshot. + await session.connection(execution_options={"isolation_level": "REPEATABLE READ"}) + account = await self._account_for_read(session, account_id) + base = [ + User.service_name == self._service_name, + User.account_pk == account.account_pk, + User.deletion_task_id.is_(None), + ] + account_total, manager_count = ( + await session.execute( + select( + func.count(), + func.coalesce(func.sum(case((User.role == Role.ADMIN, 1), else_=0)), 0), + ).where(*base) + ) + ).one() + key_count = await session.scalar( + select(func.count()) + .select_from(Credential) + .join( + User, + and_( + User.service_name == Credential.service_name, + User.user_pk == Credential.user_pk, + ), + ) + .where(*base, Credential.revoked.is_(False)) + ) + filters = list(base) + if name_filter: + filters.append(User.user_id.like(_like_pattern(name_filter), escape="=")) + if role_filter: + filters.append(User.role == role_filter) + if query := (query_filter or "").strip(): + filters.append( + func.lower(User.user_id).like( + _substring_pattern(query.casefold()), + escape="=", + ) + ) + total = await session.scalar(select(func.count()).select_from(User).where(*filters)) + statement = select(User).where(*filters).order_by(User.created_at, User.user_pk) + if limit is not None: + statement = statement.limit(limit).offset((max(1, page) - 1) * limit) + users = list((await session.scalars(statement)).all()) + credentials = {} + if users: + credentials = { + credential.user_pk: credential + for credential in ( + await session.scalars( + select(Credential).where( + Credential.service_name == self._service_name, + Credential.user_pk.in_([user.user_pk for user in users]), + Credential.revoked.is_(False), + ) + ) + ) + } + result: list[UserSummary] = [] + for user in users: + row: UserSummary = {"user_id": user.user_id, "role": user.role} + if credential := credentials.get(user.user_pk): + api_key = self._decrypt_credential(account_id, user.user_id, credential.material) + if api_key is None: + row["key_prefix"] = credential.prefix + else: + row["api_key"] = api_key + result.append(row) + return { + "users": result, + "total": total, + "account_total": account_total, + "manager_count": manager_count, + "key_count": key_count, + } + + async def set_role(self, account_id: str, user_id: str, role: str) -> None: + role = _validate_role(role) + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + user = await self._lock_user(session, account, user_id) + self._require_active(user) + if role != Role.ADMIN: + await self._protect_last_admin(session, account, user) + user.role = role + + async def ensure_trusted_identities(self, identities: dict[str, set[str]]) -> dict[str, int]: + for account_id, user_ids in identities.items(): + _validate(account_id, validate_account_id, "account_id") + for user_id in user_ids: + _validate(user_id, validate_user_id, "user_id") + created_accounts = 0 + created_users = 0 + for account_id, user_ids in identities.items(): + if not user_ids: + continue + async with self._transaction() as session: + account = await session.scalar( + select(Account) + .where( + Account.service_name == self._service_name, + Account.account_id == account_id, + ) + .with_for_update() + ) + if account is None: + try: + async with session.begin_nested(): + account = Account( + service_name=self._service_name, + account_id=account_id, + ) + session.add(account) + await session.flush() + created_accounts += 1 + except IntegrityError: + account = await self._lock_account(session, account_id) + if account.deletion_task_id: + continue + existing = set( + ( + await session.scalars( + select(User.user_id).where( + User.service_name == self._service_name, + User.account_pk == account.account_pk, + User.user_id.in_(user_ids), + ) + ) + ).all() + ) + for user_id in sorted(user_ids - existing): + session.add( + User( + service_name=self._service_name, + account_pk=account.account_pk, + user_id=user_id, + role=Role.USER, + ) + ) + created_users += 1 + return {"created_accounts": created_accounts, "created_users": created_users} + + async def begin_deletion( + self, + account_id: str, + user_id: str | None, + *, + task_id: str, + owner_account_id: str, + owner_user_id: str, + ) -> tuple[DeletionRecord, bool]: + _validate_length(owner_account_id, "owner_account_id") + _validate_length(owner_user_id, "owner_user_id") + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + record = account + if user_id is not None: + self._require_active(account) + record = await self._lock_user(session, account, user_id) + await self._protect_last_admin(session, account, record) + if current := _deletion(record): + return current, False + record.deletion_task_id = task_id + record.deletion_owner_account_id = owner_account_id + record.deletion_owner_user_id = owner_user_id + return DeletionRecord( + task_id=task_id, + owner_account_id=owner_account_id, + owner_user_id=owner_user_id, + ), True + + async def replace_deletion_task( + self, + account_id: str, + user_id: str | None, + *, + expected_task_id: str, + task_id: str, + owner_account_id: str, + owner_user_id: str, + ) -> DeletionRecord: + _validate_length(owner_account_id, "owner_account_id") + _validate_length(owner_user_id, "owner_user_id") + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + record = account + if user_id is not None: + self._require_active(account) + record = await self._lock_user(session, account, user_id) + current = _deletion(record) + if current is None or current["task_id"] != expected_task_id: + return current or DeletionRecord() + record.deletion_task_id = task_id + record.deletion_owner_account_id = owner_account_id + record.deletion_owner_user_id = owner_user_id + return DeletionRecord( + task_id=task_id, + owner_account_id=owner_account_id, + owner_user_id=owner_user_id, + ) + + async def finish_deletion(self, account_id: str, user_id: str | None, task_id: str) -> bool: + async with self._transaction() as session: + try: + account = await self._lock_account(session, account_id) + except NotFoundError: + return False + if user_id is None: + if account.deletion_task_id != task_id: + return False + await session.delete(account) + return True + if account.deletion_task_id: + return False + try: + user = await self._lock_user(session, account, user_id) + except NotFoundError: + return False + if user.deletion_task_id != task_id: + return False + await session.delete(user) + return True + + async def get_deletion( + self, account_id: str, user_id: str | None = None + ) -> DeletionRecord | None: + if user_id is None: + async with self._session_factory() as session: + account = await session.scalar( + select(Account).where( + Account.service_name == self._service_name, + Account.account_id == account_id, + ) + ) + return _deletion(account) if account else None + + async with self._session_factory() as session: + row = ( + await session.execute( + select(Account, User) + .outerjoin( + User, + and_( + User.service_name == Account.service_name, + User.account_pk == Account.account_pk, + User.user_id == user_id, + ), + ) + .where( + Account.service_name == self._service_name, + Account.account_id == account_id, + ) + ) + ).one_or_none() + if row is None: + return None + account, user = row + return _deletion(account) or (_deletion(user) if user else None) + + async def iter_deletions(self) -> list[tuple[str, str | None, DeletionRecord]]: + async with self._session_factory() as session: + accounts = ( + await session.scalars( + select(Account).where( + Account.service_name == self._service_name, + Account.deletion_task_id.is_not(None), + ) + ) + ).all() + users = ( + await session.execute( + select(Account.account_id, User) + .join( + User, + and_( + User.service_name == Account.service_name, + User.account_pk == Account.account_pk, + ), + ) + .where( + Account.service_name == self._service_name, + Account.deletion_task_id.is_(None), + User.deletion_task_id.is_not(None), + ) + ) + ).all() + return [(account.account_id, None, _deletion(account)) for account in accounts] + [ + (account_id, user.user_id, _deletion(user)) for account_id, user in users + ] + + async def create_group(self, account_id: str, group_id: str) -> GroupSummary: + _validate( + group_id, + lambda value: validate_identifier_part(value, "group_id"), + "group_id", + ) + try: + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + session.add( + Group( + service_name=self._service_name, + account_pk=account.account_pk, + group_id=group_id, + ) + ) + await session.flush() + except IntegrityError as exc: + raise AlreadyExistsError(group_id, "group") from exc + return GroupSummary(group_id=group_id, member_count=0) + + async def _account_for_read(self, session: AsyncSession, account_id: str): + account = await session.scalar( + select(Account).where( + Account.service_name == self._service_name, + Account.account_id == account_id, + ) + ) + if account is None: + raise NotFoundError(account_id, "account") + return account + + async def get_groups(self, account_id: str) -> list[GroupSummary]: + async with self._session_factory() as session: + account = await self._account_for_read(session, account_id) + rows = ( + await session.execute( + select(Group.group_id, func.count(GroupMember.user_pk)) + .outerjoin( + GroupMember, + and_( + GroupMember.service_name == Group.service_name, + GroupMember.account_pk == Group.account_pk, + GroupMember.group_pk == Group.group_pk, + ), + ) + .where( + Group.service_name == self._service_name, + Group.account_pk == account.account_pk, + ) + .group_by(Group.group_pk) + .order_by(Group.group_id) + ) + ).all() + return [GroupSummary(group_id=group_id, member_count=count) for group_id, count in rows] + + async def get_group_members(self, account_id: str, group_id: str) -> list[str]: + async with self._session_factory() as session: + account = await self._account_for_read(session, account_id) + group = await session.scalar( + select(Group).where( + Group.service_name == self._service_name, + Group.account_pk == account.account_pk, + Group.group_id == group_id, + ) + ) + if group is None: + raise NotFoundError(group_id, "group") + return list( + ( + await session.scalars( + select(User.user_id) + .join( + GroupMember, + and_( + GroupMember.service_name == User.service_name, + GroupMember.account_pk == User.account_pk, + GroupMember.user_pk == User.user_pk, + ), + ) + .where( + GroupMember.service_name == self._service_name, + GroupMember.account_pk == account.account_pk, + GroupMember.group_pk == group.group_pk, + ) + .order_by(User.user_id) + ) + ).all() + ) + + async def get_user_group_ids(self, account_id: str, user_id: str) -> tuple[str, ...]: + async with self._session_factory() as session: + group_ids = ( + await session.scalars( + select(Group.group_id) + .select_from(Account) + .join( + User, + and_( + User.service_name == Account.service_name, + User.account_pk == Account.account_pk, + ), + ) + .outerjoin( + GroupMember, + and_( + GroupMember.service_name == User.service_name, + GroupMember.account_pk == User.account_pk, + GroupMember.user_pk == User.user_pk, + ), + ) + .outerjoin( + Group, + and_( + Group.service_name == GroupMember.service_name, + Group.account_pk == GroupMember.account_pk, + Group.group_pk == GroupMember.group_pk, + ), + ) + .where( + Account.service_name == self._service_name, + Account.account_id == account_id, + User.user_id == user_id, + ) + .order_by(Group.group_id) + ) + ).all() + return tuple(group_id for group_id in group_ids if group_id is not None) + + async def add_group_member(self, account_id: str, group_id: str, user_id: str) -> bool: + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + group = await session.scalar( + select(Group) + .where( + Group.service_name == self._service_name, + Group.account_pk == account.account_pk, + Group.group_id == group_id, + ) + .with_for_update() + ) + if group is None: + raise NotFoundError(group_id, "group") + user = await self._lock_user(session, account, user_id) + self._require_active(user) + if ( + await session.scalar( + select(GroupMember.group_pk).where( + GroupMember.service_name == self._service_name, + GroupMember.account_pk == account.account_pk, + GroupMember.group_pk == group.group_pk, + GroupMember.user_pk == user.user_pk, + ) + ) + is None + ): + session.add( + GroupMember( + service_name=self._service_name, + account_pk=account.account_pk, + group_pk=group.group_pk, + user_pk=user.user_pk, + ) + ) + return True + + async def remove_group_member(self, account_id: str, group_id: str, user_id: str) -> bool: + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + group = await session.scalar( + select(Group) + .where( + Group.service_name == self._service_name, + Group.account_pk == account.account_pk, + Group.group_id == group_id, + ) + .with_for_update() + ) + if group is None: + raise NotFoundError(group_id, "group") + membership = await session.scalar( + select(GroupMember) + .join( + User, + and_( + User.service_name == GroupMember.service_name, + User.account_pk == GroupMember.account_pk, + User.user_pk == GroupMember.user_pk, + ), + ) + .where( + GroupMember.service_name == self._service_name, + GroupMember.account_pk == account.account_pk, + GroupMember.group_pk == group.group_pk, + User.account_pk == account.account_pk, + User.user_id == user_id, + ) + ) + if membership is None: + return False + await session.delete(membership) + return True + + async def delete_group(self, account_id: str, group_id: str) -> None: + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + group = await session.scalar( + select(Group) + .where( + Group.service_name == self._service_name, + Group.account_pk == account.account_pk, + Group.group_id == group_id, + ) + .with_for_update() + ) + if group is None: + raise NotFoundError(group_id, "group") + if await session.scalar( + select(func.count()) + .select_from(GroupMember) + .where( + GroupMember.service_name == self._service_name, + GroupMember.account_pk == account.account_pk, + GroupMember.group_pk == group.group_pk, + ) + ): + raise FailedPreconditionError("Group must be empty before deletion") + await session.delete(group) + + async def replace_active_user_api_key( + self, account_id: str, user_id: str, api_key: str + ) -> None: + async with self._transaction() as session: + account = await self._lock_account(session, account_id) + self._require_active(account) + user = await self._lock_user(session, account, user_id) + self._require_active(user) + sequence = await session.scalar( + select(func.coalesce(func.max(Credential.sequence), 0)).where( + Credential.service_name == self._service_name, + Credential.user_pk == user.user_pk, + ) + ) + await session.execute( + update(Credential) + .where( + Credential.service_name == self._service_name, + Credential.user_pk == user.user_pk, + Credential.revoked.is_(False), + ) + .values( + revoked=True, + active_user_pk=None, + version=Credential.version + 1, + revoked_at=func.utc_timestamp(6), + ) + ) + session.add( + self._new_credential( + account_id, + user_id, + user.user_pk, + api_key, + sequence=sequence + 1, + ) + ) + await session.flush() + old_credentials = list( + ( + await session.scalars( + select(Credential.credential_id) + .where( + Credential.service_name == self._service_name, + Credential.user_pk == user.user_pk, + Credential.revoked.is_(True), + ) + .order_by(Credential.sequence.desc()) + .offset(_RETAINED_REVOKED_CREDENTIALS) + ) + ).all() + ) + if old_credentials: + await session.execute( + delete(Credential).where( + Credential.service_name == self._service_name, + Credential.credential_id.in_(old_credentials), + ) + ) + + async def verify_api_key( + self, + api_key: str, + *, + account_id_hint: str | None = None, + user_id_hint: str | None = None, + ) -> tuple[str, str] | None: + predicates = [ + Credential.service_name == self._service_name, + Credential.lookup_digest == _credential_digest(api_key), + Credential.revoked.is_(False), + Account.deletion_task_id.is_(None), + User.deletion_task_id.is_(None), + ] + if account_id_hint is not None: + predicates.append(Account.account_id == account_id_hint) + if user_id_hint is not None: + predicates.append(User.user_id == user_id_hint) + async with self._session_factory() as session: + candidates = ( + await session.execute( + select(Credential.material, Account.account_id, User.user_id) + .join( + User, + and_( + User.service_name == Credential.service_name, + User.user_pk == Credential.user_pk, + ), + ) + .join( + Account, + and_( + Account.service_name == User.service_name, + Account.account_pk == User.account_pk, + ), + ) + .where(*predicates) + ) + ).all() + for material, candidate_account_id, candidate_user_id in candidates: + if self._matches_credential(candidate_account_id, candidate_user_id, material, api_key): + return candidate_account_id, candidate_user_id + return None + + async def revoke_active_api_keys(self, account_id: str, user_id: str | None = None) -> None: + async with self._transaction() as session: + account = await session.scalar( + select(Account) + .where( + Account.service_name == self._service_name, + Account.account_id == account_id, + ) + .with_for_update() + ) + if account is None: + return + user_ids = select(User.user_pk).where( + User.service_name == self._service_name, + User.account_pk == account.account_pk, + *([User.user_id == user_id] if user_id is not None else []), + ) + await session.execute( + update(Credential) + .where( + Credential.service_name == self._service_name, + Credential.user_pk.in_(user_ids), + Credential.revoked.is_(False), + ) + .values( + revoked=True, + active_user_pk=None, + version=Credential.version + 1, + revoked_at=func.utc_timestamp(6), + ) + ) + + async def get_user_key_fingerprint(self, account_id: str, user_id: str) -> str | None: + async with self._session_factory() as session: + material = await session.scalar( + select(Credential.material) + .join( + User, + and_( + User.service_name == Credential.service_name, + User.user_pk == Credential.user_pk, + ), + ) + .join( + Account, + and_( + Account.service_name == User.service_name, + Account.account_pk == User.account_pk, + ), + ) + .where( + Credential.service_name == self._service_name, + Credential.revoked.is_(False), + Account.account_id == account_id, + Account.deletion_task_id.is_(None), + User.user_id == user_id, + User.deletion_task_id.is_(None), + ) + ) + return hashlib.sha256(material.encode()).hexdigest() if material else None diff --git a/openviking/server/account_stores/mysql_schema.py b/openviking/server/account_stores/mysql_schema.py new file mode 100644 index 0000000000..9cd2f62450 --- /dev/null +++ b/openviking/server/account_stores/mysql_schema.py @@ -0,0 +1,247 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Fixed SQLAlchemy schema for the MySQL account-store provider. + +``service_name`` is the tenant boundary. It is deliberately present in every +primary lookup, unique key, and foreign-key relationship so one database can +hold many OpenViking deployments safely. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import ( + BigInteger, + Boolean, + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.mysql import DATETIME +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class Account(Base): + __tablename__ = "ov_accounts" + __table_args__ = ( + UniqueConstraint( + "service_name", + "account_id", + name="uq_ov_accounts_account_identity", + ), + UniqueConstraint( + "service_name", + "account_pk", + name="uq_ov_accounts_account_service_pk", + ), + Index( + "ix_ov_accounts_account_deletion", + "service_name", + "deletion_task_id", + ), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + account_pk: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + service_name: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + account_id: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + created_at: Mapped[object] = mapped_column( + DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + ) + deletion_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + deletion_owner_account_id: Mapped[Optional[str]] = mapped_column( + String(255, collation="utf8mb4_bin"), nullable=True + ) + deletion_owner_user_id: Mapped[Optional[str]] = mapped_column( + String(255, collation="utf8mb4_bin"), nullable=True + ) + + +class User(Base): + __tablename__ = "ov_accounts_users" + __table_args__ = ( + ForeignKeyConstraint( + ["service_name", "account_pk"], + ["ov_accounts.service_name", "ov_accounts.account_pk"], + ondelete="CASCADE", + name="fk_ov_accounts_user_account", + ), + UniqueConstraint( + "service_name", + "account_pk", + "user_id", + name="uq_ov_accounts_user_identity", + ), + UniqueConstraint( + "service_name", + "user_pk", + name="uq_ov_accounts_user_service_pk", + ), + UniqueConstraint( + "service_name", + "account_pk", + "user_pk", + name="uq_ov_accounts_user_account_pk", + ), + Index( + "ix_ov_accounts_user_admin", + "service_name", + "account_pk", + "role", + ), + Index( + "ix_ov_accounts_user_deletion", + "service_name", + "deletion_task_id", + ), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + user_pk: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + service_name: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + account_pk: Mapped[int] = mapped_column(BigInteger) + user_id: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + role: Mapped[str] = mapped_column(String(16)) + created_at: Mapped[object] = mapped_column( + DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + ) + deletion_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + deletion_owner_account_id: Mapped[Optional[str]] = mapped_column( + String(255, collation="utf8mb4_bin"), nullable=True + ) + deletion_owner_user_id: Mapped[Optional[str]] = mapped_column( + String(255, collation="utf8mb4_bin"), nullable=True + ) + + +class Group(Base): + __tablename__ = "ov_accounts_groups" + __table_args__ = ( + ForeignKeyConstraint( + ["service_name", "account_pk"], + ["ov_accounts.service_name", "ov_accounts.account_pk"], + ondelete="CASCADE", + name="fk_ov_accounts_group_account", + ), + UniqueConstraint( + "service_name", + "account_pk", + "group_id", + name="uq_ov_accounts_group_identity", + ), + UniqueConstraint( + "service_name", + "group_pk", + name="uq_ov_accounts_group_service_pk", + ), + UniqueConstraint( + "service_name", + "account_pk", + "group_pk", + name="uq_ov_accounts_group_account_pk", + ), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + group_pk: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + service_name: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + account_pk: Mapped[int] = mapped_column(BigInteger) + group_id: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + created_at: Mapped[object] = mapped_column( + DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + ) + + +class GroupMember(Base): + __tablename__ = "ov_accounts_group_members" + __table_args__ = ( + PrimaryKeyConstraint("service_name", "account_pk", "group_pk", "user_pk"), + ForeignKeyConstraint( + ["service_name", "account_pk", "group_pk"], + [ + "ov_accounts_groups.service_name", + "ov_accounts_groups.account_pk", + "ov_accounts_groups.group_pk", + ], + ondelete="CASCADE", + name="fk_ov_accounts_member_group", + ), + ForeignKeyConstraint( + ["service_name", "account_pk", "user_pk"], + [ + "ov_accounts_users.service_name", + "ov_accounts_users.account_pk", + "ov_accounts_users.user_pk", + ], + ondelete="CASCADE", + name="fk_ov_accounts_member_user", + ), + Index( + "ix_ov_accounts_member_user", + "service_name", + "account_pk", + "user_pk", + ), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + service_name: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + account_pk: Mapped[int] = mapped_column(BigInteger) + group_pk: Mapped[int] = mapped_column(BigInteger) + user_pk: Mapped[int] = mapped_column(BigInteger) + + +class Credential(Base): + __tablename__ = "ov_accounts_credentials" + __table_args__ = ( + ForeignKeyConstraint( + ["service_name", "user_pk"], + ["ov_accounts_users.service_name", "ov_accounts_users.user_pk"], + ondelete="CASCADE", + name="fk_ov_accounts_credential_user", + ), + UniqueConstraint( + "service_name", + "active_user_pk", + name="uq_ov_accounts_active_credential", + ), + Index( + "ix_ov_accounts_credential_lookup", + "service_name", + "lookup_digest", + "revoked", + "user_pk", + ), + Index( + "ix_ov_accounts_credential_history", + "service_name", + "user_pk", + "revoked", + "sequence", + ), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + credential_id: Mapped[str] = mapped_column(String(32, collation="ascii_bin"), primary_key=True) + service_name: Mapped[str] = mapped_column(String(255, collation="utf8mb4_bin")) + user_pk: Mapped[int] = mapped_column(BigInteger) + material: Mapped[str] = mapped_column(Text(collation="ascii_bin")) + lookup_digest: Mapped[str] = mapped_column(String(64, collation="ascii_bin")) + prefix: Mapped[str] = mapped_column(String(8, collation="ascii_bin")) + revoked: Mapped[bool] = mapped_column(Boolean, server_default=text("0")) + version: Mapped[int] = mapped_column(BigInteger, server_default=text("1")) + sequence: Mapped[int] = mapped_column(BigInteger) + created_at: Mapped[object] = mapped_column( + DATETIME(fsp=6), server_default=text("CURRENT_TIMESTAMP(6)") + ) + revoked_at: Mapped[Optional[object]] = mapped_column(DATETIME(fsp=6), nullable=True) + active_user_pk: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) diff --git a/openviking/server/account_stores/redis_client.py b/openviking/server/account_stores/redis_client.py new file mode 100644 index 0000000000..f96f8c2572 --- /dev/null +++ b/openviking/server/account_stores/redis_client.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Topology-aware asyncio Redis client construction for account stores.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +from openviking_cli.exceptions import InvalidArgumentError +from openviking_cli.utils.config.agfs_config import RedisCacheConfig + + +def _secret(value: str, env_name: str, field_name: str) -> str | None: + if value and env_name: + raise InvalidArgumentError(f"{field_name} and {field_name}_env cannot both be configured") + if env_name: + resolved = os.environ.get(env_name) + if not resolved: + raise InvalidArgumentError( + f"{field_name}_env points to an unset or empty environment variable" + ) + return resolved + return value or None + + +def _tls_options( + config: RedisCacheConfig, + scheme: str, + *, + include_ssl_flag: bool, +) -> dict[str, Any]: + if scheme == "rediss": + options = { + "ssl_cert_reqs": "none" if config.tls_insecure_skip_verify else "required", + } + if include_ssl_flag: + options["ssl"] = True + return options + return {} + + +def _endpoint_parts(config: RedisCacheConfig) -> list[tuple[str, int]]: + result: list[tuple[str, int]] = [] + for endpoint in config.endpoints: + parsed = urlparse(endpoint) + result.append((parsed.hostname or "", parsed.port or 6379)) + return result + + +@dataclass +class RedisClientHandle: + """A Redis client plus Sentinel discovery clients owned by the factory.""" + + client: Any + sentinel_clients: tuple[Any, ...] = () + + async def close(self) -> None: + try: + close = getattr(self.client, "aclose", None) + if close is not None: + await close() + finally: + for sentinel in self.sentinel_clients: + close = getattr(sentinel, "aclose", None) + if close is not None: + await close() + + +def create_redis_client(raw_params: dict[str, object]) -> RedisClientHandle: + """Create a redis-py client for standalone, cluster, or Sentinel Redis.""" + try: + config = RedisCacheConfig.model_validate(raw_params) + except Exception as exc: + raise InvalidArgumentError(f"Invalid Redis cache configuration: {exc}") from exc + + password = _secret(config.password, config.password_env, "cache.params.password") + sentinel_password = _secret( + config.sentinel_password, + config.sentinel_password_env, + "cache.params.sentinel_password", + ) + scheme = urlparse(config.endpoints[0]).scheme + common: dict[str, Any] = { + "decode_responses": True, + "socket_connect_timeout": config.connect_timeout_ms / 1000, + "socket_timeout": config.command_timeout_ms / 1000, + "username": config.username or None, + "password": password, + "max_connections": config.pool_size, + } + + if config.mode == "standalone": + from redis.asyncio import Redis + + return RedisClientHandle( + Redis.from_url( + config.endpoints[0], + db=config.db, + **common, + **_tls_options(config, scheme, include_ssl_flag=False), + ) + ) + + if config.mode == "cluster": + from redis.asyncio.cluster import ClusterNode, RedisCluster + + return RedisClientHandle( + RedisCluster( + startup_nodes=[ClusterNode(host, port) for host, port in _endpoint_parts(config)], + db=0, + **common, + **_tls_options(config, scheme, include_ssl_flag=True), + ) + ) + + from redis.asyncio.sentinel import Sentinel + + sentinel_common = { + "decode_responses": True, + "socket_connect_timeout": config.connect_timeout_ms / 1000, + "socket_timeout": config.command_timeout_ms / 1000, + **_tls_options(config, scheme, include_ssl_flag=True), + } + sentinel = Sentinel( + _endpoint_parts(config), + sentinel_kwargs={ + **sentinel_common, + "username": config.sentinel_username or None, + "password": sentinel_password, + }, + **common, + **_tls_options(config, scheme, include_ssl_flag=True), + db=config.db, + ) + return RedisClientHandle( + sentinel.master_for( + config.master_name or "", + **common, + **_tls_options(config, scheme, include_ssl_flag=True), + ), + tuple(sentinel.sentinels), + ) diff --git a/openviking/server/account_stores/redis_mysql.py b/openviking/server/account_stores/redis_mysql.py new file mode 100644 index 0000000000..fbf2f70ce0 --- /dev/null +++ b/openviking/server/account_stores/redis_mysql.py @@ -0,0 +1,943 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Redis read-through cache over the authoritative MySQL account store.""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import secrets +import time +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from typing import Any, TypeVar +from urllib.parse import parse_qs, unquote, urlsplit + +from openviking.server.account_stores.base import AccountStore +from openviking.server.account_stores.models import ( + AccountSummary, + DeletionRecord, + GroupSummary, + UsersPage, + UserSummary, +) +from openviking.server.account_stores.mysql import MySQLAccountStore +from openviking.server.account_stores.redis_client import ( + RedisClientHandle, + create_redis_client, +) +from openviking_cli.exceptions import InvalidArgumentError +from openviking_cli.utils import get_logger +from openviking_cli.utils.config.config_utils import normalize_service_name + +logger = get_logger(__name__) +T = TypeVar("T") + +_RELEASE_LOCK_SCRIPT = """ +-- release-lock +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +""" + +_GET_IF_LOCKED_SCRIPT = """ +-- get-if-locked +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return {0} +end +local value = redis.call('GET', KEYS[2]) +if value then + return {2, value} +end +return {1} +""" + +_SET_IF_LOCKED_SCRIPT = """ +-- set-if-locked +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 0 +end +redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[3]) +if ARGV[5] == '1' then + local ttl = redis.call('PTTL', KEYS[2]) + if ttl <= 0 then + redis.call('DEL', KEYS[2]) + return 0 + end + local now = redis.call('TIME') + local now_ms = now[1] * 1000 + math.floor(now[2] / 1000) + redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', now_ms) + redis.call('ZADD', KEYS[3], now_ms + ttl, KEYS[2]) + redis.call('EXPIRE', KEYS[3], ARGV[4]) +end +return 1 +""" + +_DELETE_AND_UNINDEX_SCRIPT = """ +-- delete-and-unindex +if #KEYS == 1 then + return 0 +end +local deleted = redis.call('DEL', unpack(KEYS, 2)) +redis.call('ZREM', KEYS[1], unpack(KEYS, 2)) +return deleted +""" + +_PRUNE_CACHE_INDEX_SCRIPT = """ +-- prune-cache-index +local now = redis.call('TIME') +local now_ms = now[1] * 1000 + math.floor(now[2] / 1000) +return redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now_ms) +""" + +_ACCOUNT_CACHE_CLEAR_BATCH_SIZE = 500 + + +class _CacheLockUnavailable(Exception): + """Redis could not provide the lock needed to coordinate a cache fill.""" + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest() + + +def _key_part(value: str) -> str: + return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=") + + +def _decode(raw: str | bytes | None) -> dict[str, Any] | None: + if raw is None: + return None + try: + value = json.loads(raw) + except (TypeError, ValueError, UnicodeError): + return None + return value if isinstance(value, dict) else None + + +def _legacy_redis_cache_params(values: dict[str, object]) -> dict[str, object]: + url = values.pop("url", None) + if not isinstance(url, str) or not url: + raise InvalidArgumentError("redis.url must be a non-empty string") + parsed = urlsplit(url) + if parsed.scheme not in {"redis", "rediss"} or not parsed.hostname: + raise InvalidArgumentError("redis.url must use a redis:// or rediss:// URL") + try: + port = parsed.port or 6379 + except ValueError as exc: + raise InvalidArgumentError("redis.url port is invalid") from exc + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + params: dict[str, object] = { + "mode": "standalone", + "endpoints": [f"{parsed.scheme}://{host}:{port}"], + **values, + } + if parsed.username and "username" not in params: + params["username"] = unquote(parsed.username) + if parsed.password and "password" not in params: + params["password"] = unquote(parsed.password) + if parsed.path not in {"", "/"} and "db" not in params: + try: + params["db"] = int(parsed.path.lstrip("/")) + except ValueError as exc: + raise InvalidArgumentError("redis.url database path is invalid") from exc + query = parse_qs(parsed.query) + for query_name, config_name in ( + ("socket_connect_timeout", "connect_timeout_ms"), + ("socket_timeout", "command_timeout_ms"), + ): + if query_name in query and config_name not in params: + try: + params[config_name] = max(1, int(float(query[query_name][0]) * 1000)) + except (IndexError, ValueError) as exc: + raise InvalidArgumentError(f"redis.url {query_name} is invalid") from exc + return params + + +class RedisMySQLAccountStore(AccountStore): + """Cache hot account reads while preserving MySQL as the authority. + + A Redis lease coordinates cache fills and mutations per account. If Redis + is unavailable, MySQL operations still proceed and cache invalidation is + best-effort; any stale value is bounded by the fixed cache TTL. + """ + + def __init__( + self, + *, + service_name: str, + params: dict[str, object] | None = None, + mysql_store: MySQLAccountStore | None = None, + redis_client: Any | None = None, + ) -> None: + try: + normalized_service_name = normalize_service_name(service_name) + except ValueError as exc: + raise InvalidArgumentError(str(exc)) from None + values = dict(params or {}) + mysql_params = values.pop("mysql", None) + cache_spec = values.pop("cache", None) + legacy_redis_params = values.pop("redis", None) + if values: + names = ", ".join(sorted(values)) + raise InvalidArgumentError(f"Unknown Redis MySQL account store parameters: {names}") + if mysql_store is None: + if not isinstance(mysql_params, dict): + raise InvalidArgumentError("redis_mysql account storage requires mysql parameters") + mysql_store = MySQLAccountStore( + service_name=normalized_service_name, + params=mysql_params, + ) + elif mysql_store._service_name != normalized_service_name: + raise InvalidArgumentError( + "Redis and MySQL account stores must use the same service_name" + ) + if cache_spec is None and legacy_redis_params is not None: + if not isinstance(legacy_redis_params, dict): + raise InvalidArgumentError("redis_mysql account storage requires cache parameters") + legacy_values = dict(legacy_redis_params) + cache_spec = { + "provider": "redis", + "params": _legacy_redis_cache_params(legacy_values), + } + if not isinstance(cache_spec, dict): + raise InvalidArgumentError("redis_mysql account storage requires cache parameters") + unknown_cache_fields = set(cache_spec) - {"provider", "params"} + if unknown_cache_fields: + names = ", ".join(sorted(unknown_cache_fields)) + raise InvalidArgumentError(f"Unknown Redis cache fields: {names}") + cache_provider = cache_spec.get("provider") + cache_params = cache_spec.get("params") + if cache_provider != "redis" or not isinstance(cache_params, dict): + raise InvalidArgumentError( + "redis_mysql account storage cache must use provider='redis' with a params object" + ) + redis_values = dict(cache_params) + self._key_prefix = redis_values.pop("key_prefix", "ov:account-cache") + self._ttl_seconds = redis_values.pop( + "ttl_seconds", + redis_values.get("default_ttl_seconds", 60), + ) + self._lock_ttl_seconds = redis_values.pop("lock_ttl_seconds", 60) + self._lock_wait_seconds = redis_values.pop("lock_wait_seconds", 30) + redis_values.setdefault("connect_timeout_ms", 5000) + redis_values.setdefault("command_timeout_ms", 5000) + if not isinstance(self._key_prefix, str) or not self._key_prefix: + raise InvalidArgumentError("cache.params.key_prefix must be a non-empty string") + if "{" in self._key_prefix or "}" in self._key_prefix: + raise InvalidArgumentError( + "cache.params.key_prefix must not contain Redis hash-tag characters" + ) + for name in ("ttl_seconds", "lock_ttl_seconds", "lock_wait_seconds"): + value = getattr(self, f"_{name}") + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise InvalidArgumentError(f"cache.params.{name} must be a positive integer") + if self._lock_ttl_seconds < self._ttl_seconds: + raise InvalidArgumentError( + "cache.params.lock_ttl_seconds must be at least cache.params.ttl_seconds" + ) + + self._mysql = mysql_store + self._index_ttl_seconds = self._ttl_seconds + self._lock_ttl_seconds + self._service_name = normalized_service_name + self._redis = redis_client + self._redis_config = redis_values + self._redis_handle: RedisClientHandle | None = None + self._owns_redis = redis_client is None + + async def load(self) -> None: + await self._mysql.load() + try: + if self._redis is None: + try: + self._redis_handle = create_redis_client(self._redis_config) + except ImportError: + raise RuntimeError( + "Redis MySQL account storage requires openviking[redis-mysql]" + ) from None + self._redis = self._redis_handle.client + await self._redis.ping() + except BaseException: + await self.close() + raise + + async def close(self) -> None: + try: + if self._redis_handle is not None and self._owns_redis: + await self._redis_handle.close() + finally: + self._redis = None + self._redis_handle = None + await self._mysql.close() + + def _account_tag(self, account_id: str) -> str: + return _key_part(f"{self._service_name}\0{account_id}") + + def _key(self, account_id: str, suffix: str) -> str: + return f"{self._key_prefix}:v1:{{{self._account_tag(account_id)}}}:{suffix}" + + def _lock_key(self, account_id: str) -> str: + return self._key(account_id, "lock") + + def _cache_keys_index_key(self, account_id: str) -> str: + return self._key(account_id, "cache-keys") + + def _user_key(self, account_id: str, user_id: str) -> str: + return self._key(account_id, f"user:{_key_part(user_id)}") + + def _group_ids_key(self, account_id: str, user_id: str) -> str: + return self._key(account_id, f"groups:{_key_part(user_id)}") + + def _account_deletion_key(self, account_id: str) -> str: + return self._key(account_id, "deletion:account") + + def _user_deletion_key(self, account_id: str, user_id: str) -> str: + return self._key(account_id, f"deletion:user:{_key_part(user_id)}") + + def _account_credential_fence_key(self, account_id: str) -> str: + return self._key(account_id, "credential-fence:account") + + def _user_credential_fence_key(self, account_id: str, user_id: str) -> str: + return self._key(account_id, f"credential-fence:user:{_key_part(user_id)}") + + def _binding_key(self, account_id: str, api_key: str) -> str: + digest_input = self._service_name + "\0" + api_key + return self._key(account_id, f"binding:{_digest(digest_input)}") + + def _negative_binding_key(self, account_id: str, api_key: str, user_id_hint: str | None) -> str: + digest_input = self._service_name + "\0" + api_key + "\0" + (user_id_hint or "") + return self._key(account_id, f"binding-miss:{_digest(digest_input)}") + + def _binding_cache_keys(self, account_id: str, api_key: str, user_id: str) -> tuple[str, ...]: + return ( + self._binding_key(account_id, api_key), + self._negative_binding_key(account_id, api_key, user_id), + self._negative_binding_key(account_id, api_key, None), + ) + + async def _delete(self, *keys: str) -> None: + assert self._redis is not None + if keys: + await self._redis.delete(*keys) + + @asynccontextmanager + async def _account_lock(self, account_id: str) -> AsyncIterator[str]: + assert self._redis is not None + key = self._lock_key(account_id) + token = secrets.token_urlsafe(24) + deadline = time.monotonic() + self._lock_wait_seconds + try: + while True: + if await self._redis.set( + key, + token, + nx=True, + px=self._lock_ttl_seconds * 1000, + ): + break + if time.monotonic() >= deadline: + raise _CacheLockUnavailable( + f"timed out waiting for account cache lock: {account_id}" + ) + await asyncio.sleep(0.02 + secrets.randbelow(20) / 1000) + except _CacheLockUnavailable: + raise + except Exception as exc: + raise _CacheLockUnavailable( + f"failed to acquire account cache lock: {account_id}" + ) from exc + try: + yield token + finally: + try: + await self._redis.eval(_RELEASE_LOCK_SCRIPT, 1, key, token) + except Exception: + logger.exception("Failed to release account cache lock") + + @staticmethod + def _nullable_payload(value: dict[str, Any] | tuple[str, ...] | None) -> dict[str, Any]: + return {"found": value is not None, "value": value} + + @staticmethod + def _read_nullable(payload: dict[str, Any] | None) -> tuple[bool, Any]: + if payload is None or not isinstance(payload.get("found"), bool): + return False, None + return True, payload.get("value") + + async def _get_if_locked( + self, account_id: str, token: str, key: str + ) -> tuple[bool, dict[str, Any] | None]: + assert self._redis is not None + result = await self._redis.eval( + _GET_IF_LOCKED_SCRIPT, + 2, + self._lock_key(account_id), + key, + token, + ) + if not isinstance(result, (list, tuple)) or not result: + return False, None + status = int(result[0]) + if status == 0: + return False, None + if status == 1: + return True, None + return True, _decode(result[1]) + + async def _set_if_locked( + self, + account_id: str, + token: str, + key: str, + value: dict[str, Any], + *, + index: bool = True, + ) -> bool: + assert self._redis is not None + result = await self._redis.eval( + _SET_IF_LOCKED_SCRIPT, + 3, + self._lock_key(account_id), + key, + self._cache_keys_index_key(account_id), + token, + json.dumps(value, separators=(",", ":")), + self._ttl_seconds, + self._index_ttl_seconds, + int(index), + ) + return bool(result) + + async def _read_through( + self, + account_id: str, + key: str, + loader: Callable[[], Awaitable[T]], + ) -> T: + try: + hit, value = self._read_nullable(_decode(await self._redis.get(key))) + if hit: + return value + except Exception: + logger.exception("Redis cache read failed; reading %s from MySQL", key) + return await loader() + + try: + async with self._account_lock(account_id) as token: + owned, payload = await self._get_if_locked(account_id, token, key) + if not owned: + logger.warning("Redis cache lease expired before reading %s", key) + return await loader() + hit, value = self._read_nullable(payload) + if hit: + return value + value = await loader() + try: + if not await self._set_if_locked( + account_id, + token, + key, + self._nullable_payload(value), + ): + logger.warning("Redis cache lease expired before writing %s", key) + except Exception: + logger.exception("Failed to populate Redis account cache for %s", key) + return value + except _CacheLockUnavailable: + logger.warning("Redis cache lock unavailable; reading %s from MySQL", key) + except Exception: + logger.exception("Redis cache read failed; reading %s from MySQL", key) + return await loader() + + async def _delete_and_unindex(self, account_id: str, *keys: str) -> None: + assert self._redis is not None + if keys: + await self._redis.eval( + _DELETE_AND_UNINDEX_SCRIPT, + len(keys) + 1, + self._cache_keys_index_key(account_id), + *keys, + ) + + async def _invalidate(self, account_id: str, *keys: str) -> None: + try: + await self._delete_and_unindex(account_id, *keys) + except Exception: + logger.exception("Failed to invalidate Redis account cache") + + async def _prune_cache_index(self, account_id: str) -> None: + assert self._redis is not None + await self._redis.eval( + _PRUNE_CACHE_INDEX_SCRIPT, + 1, + self._cache_keys_index_key(account_id), + ) + + async def _clear_account_cache(self, account_id: str) -> None: + assert self._redis is not None + index_key = self._cache_keys_index_key(account_id) + try: + await self._prune_cache_index(account_id) + while True: + keys = await self._redis.zrange( + index_key, + 0, + _ACCOUNT_CACHE_CLEAR_BATCH_SIZE - 1, + ) + if not keys: + break + await self._delete_and_unindex(account_id, *keys) + # ZREM removes an emptied sorted set automatically. A separate DEL + # could remove a newer fill's index after this lease is lost. + except Exception: + logger.exception("Failed to clear Redis account cache for %s", account_id) + + async def _invalidate_after_lock_timeout( + self, + account_id: str, + keys: tuple[str, ...], + *, + account_wide: bool, + ) -> None: + try: + await self._delete(self._lock_key(account_id)) + except Exception: + logger.exception("Failed to revoke Redis account cache lock") + try: + async with self._account_lock(account_id): + if account_wide: + await self._clear_account_cache(account_id) + if keys: + await self._invalidate(account_id, *keys) + except _CacheLockUnavailable: + logger.warning( + "Redis cache lock remained unavailable after revoking the lease for %s", + account_id, + ) + if account_wide: + await self._clear_account_cache(account_id) + if keys: + await self._invalidate(account_id, *keys) + + async def _mutate_account( + self, + account_id: str, + operation: Callable[[], Awaitable[T]], + invalidation_keys: Callable[[], tuple[str, ...]], + *, + account_wide: bool = False, + ) -> T: + result = await operation() + keys = invalidation_keys() + try: + async with self._account_lock(account_id): + if account_wide: + await self._clear_account_cache(account_id) + if keys: + await self._invalidate(account_id, *keys) + except _CacheLockUnavailable: + logger.warning( + "Redis cache lock unavailable after mutating %s; " + "revoking the lease and invalidating without the lock", + account_id, + ) + await self._invalidate_after_lock_timeout( + account_id, + keys, + account_wide=account_wide, + ) + return result + + def _user_cache_keys(self, account_id: str, user_id: str) -> tuple[str, ...]: + return ( + self._user_key(account_id, user_id), + self._group_ids_key(account_id, user_id), + self._user_deletion_key(account_id, user_id), + self._user_credential_fence_key(account_id, user_id), + ) + + async def _cached_binding( + self, + account_id: str, + api_key: str, + user_id_hint: str | None, + ) -> tuple[bool, tuple[str, str] | None]: + binding = _decode(await self._redis.get(self._binding_key(account_id, api_key))) + hit, value = self._read_nullable(binding) + if hit and value is None: + return True, None + if isinstance(value, dict): + try: + binding_account_id = str(value["account_id"]) + user_id = str(value["user_id"]) + account_fence = str(value["account_fence"]) + user_fence = str(value["user_fence"]) + except (KeyError, TypeError, ValueError): + pass + else: + if user_id_hint is None or user_id == user_id_hint: + current_account_fence = _decode( + await self._redis.get( + self._account_credential_fence_key(binding_account_id) + ) + ) + current_user_fence = _decode( + await self._redis.get( + self._user_credential_fence_key(binding_account_id, user_id) + ) + ) + if ( + current_account_fence is not None + and current_user_fence is not None + and current_account_fence.get("value") == account_fence + and current_user_fence.get("value") == user_fence + ): + return True, (binding_account_id, user_id) + negative = _decode( + await self._redis.get(self._negative_binding_key(account_id, api_key, user_id_hint)) + ) + negative_hit, negative_value = self._read_nullable(negative) + return (True, None) if negative_hit and negative_value is None else (False, None) + + async def _cache_binding( + self, + account_id: str, + token: str, + api_key: str, + binding: tuple[str, str] | None, + user_id_hint: str | None, + ) -> None: + if binding is None: + await self._set_if_locked( + account_id, + token, + self._negative_binding_key(account_id, api_key, user_id_hint), + self._nullable_payload(None), + index=False, + ) + return + binding_account_id, user_id = binding + if binding_account_id != account_id: + return + account_fence = secrets.token_urlsafe(18) + user_fence = secrets.token_urlsafe(18) + account_fence_key = self._account_credential_fence_key(account_id) + user_fence_key = self._user_credential_fence_key(account_id, user_id) + existing_account_fence = _decode(await self._redis.get(account_fence_key)) + existing_user_fence = _decode(await self._redis.get(user_fence_key)) + if existing_account_fence is not None: + account_fence = str(existing_account_fence.get("value", account_fence)) + elif not await self._set_if_locked( + account_id, + token, + account_fence_key, + self._nullable_payload(account_fence), + ): + return + if existing_user_fence is not None: + user_fence = str(existing_user_fence.get("value", user_fence)) + elif not await self._set_if_locked( + account_id, + token, + user_fence_key, + self._nullable_payload(user_fence), + ): + return + await self._set_if_locked( + account_id, + token, + self._binding_key(account_id, api_key), + self._nullable_payload( + { + "account_id": account_id, + "user_id": user_id, + "account_fence": account_fence, + "user_fence": user_fence, + } + ), + ) + + async def verify_api_key( + self, + api_key: str, + *, + account_id_hint: str | None = None, + user_id_hint: str | None = None, + ) -> tuple[str, str] | None: + if account_id_hint is None: + return await self._mysql.verify_api_key( + api_key, account_id_hint=None, user_id_hint=user_id_hint + ) + try: + hit, binding = await self._cached_binding( + account_id_hint, + api_key, + user_id_hint, + ) + if hit: + return binding + except Exception: + logger.exception("Redis API-key cache read failed; falling back to MySQL") + try: + async with self._account_lock(account_id_hint) as token: + hit, binding = await self._cached_binding( + account_id_hint, + api_key, + user_id_hint, + ) + if hit: + return binding + binding = await self._mysql.verify_api_key( + api_key, + account_id_hint=account_id_hint, + user_id_hint=user_id_hint, + ) + try: + await self._cache_binding( + account_id_hint, + token, + api_key, + binding, + user_id_hint, + ) + except Exception: + logger.exception("Failed to populate Redis API-key cache") + return binding + except _CacheLockUnavailable: + logger.warning("Redis cache lock unavailable; verifying API key in MySQL") + except Exception: + logger.exception("Redis API-key cache write failed; verifying in MySQL") + return await self._mysql.verify_api_key( + api_key, account_id_hint=account_id_hint, user_id_hint=user_id_hint + ) + + async def get_user(self, account_id: str, user_id: str) -> UserSummary | None: + value = await self._read_through( + account_id, + self._user_key(account_id, user_id), + lambda: self._mysql.get_user(account_id, user_id), + ) + return value if value is None else UserSummary(value) + + async def get_user_group_ids(self, account_id: str, user_id: str) -> tuple[str, ...]: + value = await self._read_through( + account_id, + self._group_ids_key(account_id, user_id), + lambda: self._mysql.get_user_group_ids(account_id, user_id), + ) + return tuple(value or ()) + + async def get_deletion( + self, account_id: str, user_id: str | None = None + ) -> DeletionRecord | None: + account_deletion = await self._read_through( + account_id, + self._account_deletion_key(account_id), + lambda: self._mysql.get_deletion(account_id), + ) + if account_deletion is not None or user_id is None: + return account_deletion + value = await self._read_through( + account_id, + self._user_deletion_key(account_id, user_id), + lambda: self._mysql.get_deletion(account_id, user_id), + ) + return value + + async def iter_deletions(self) -> list[tuple[str, str | None, DeletionRecord]]: + return await self._mysql.iter_deletions() + + async def get_account(self, account_id: str) -> AccountSummary | None: + return await self._mysql.get_account(account_id) + + async def create_account( + self, + account_id: str, + admin_user_id: str, + ) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.create_account(account_id, admin_user_id), + lambda: self._user_cache_keys(account_id, admin_user_id), + account_wide=True, + ) + + async def create_account_with_api_key( + self, + account_id: str, + admin_user_id: str, + api_key: str, + ) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.create_account_with_api_key( + account_id, + admin_user_id, + api_key, + ), + lambda: self._binding_cache_keys(account_id, api_key, admin_user_id), + account_wide=True, + ) + + async def delete_account(self, account_id: str) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.delete_account(account_id), + lambda: (), + account_wide=True, + ) + + async def list_accounts(self, **kwargs) -> list[AccountSummary]: + return await self._mysql.list_accounts(**kwargs) + + async def create_user(self, account_id: str, user_id: str, role: str) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.create_user(account_id, user_id, role), + lambda: self._user_cache_keys(account_id, user_id), + ) + + async def create_user_with_api_key( + self, + account_id: str, + user_id: str, + role: str, + api_key: str, + ) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.create_user_with_api_key( + account_id, + user_id, + role, + api_key, + ), + lambda: ( + self._user_cache_keys(account_id, user_id) + + self._binding_cache_keys(account_id, api_key, user_id) + ), + ) + + async def list_users_page(self, account_id: str, **kwargs) -> UsersPage: + return await self._mysql.list_users_page(account_id, **kwargs) + + async def set_role(self, account_id: str, user_id: str, role: str) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.set_role(account_id, user_id, role), + lambda: (self._user_key(account_id, user_id),), + ) + + async def create_group(self, account_id: str, group_id: str) -> GroupSummary: + return await self._mutate_account( + account_id, + lambda: self._mysql.create_group(account_id, group_id), + lambda: (), + ) + + async def get_groups(self, account_id: str) -> list[GroupSummary]: + return await self._mysql.get_groups(account_id) + + async def get_group_members(self, account_id: str, group_id: str) -> list[str]: + return await self._mysql.get_group_members(account_id, group_id) + + async def add_group_member(self, account_id: str, group_id: str, user_id: str) -> bool: + return await self._mutate_account( + account_id, + lambda: self._mysql.add_group_member(account_id, group_id, user_id), + lambda: (self._group_ids_key(account_id, user_id),), + ) + + async def remove_group_member(self, account_id: str, group_id: str, user_id: str) -> bool: + return await self._mutate_account( + account_id, + lambda: self._mysql.remove_group_member(account_id, group_id, user_id), + lambda: (self._group_ids_key(account_id, user_id),), + ) + + async def delete_group(self, account_id: str, group_id: str) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.delete_group(account_id, group_id), + lambda: (), + ) + + async def ensure_trusted_identities(self, identities: dict[str, set[str]]) -> dict[str, int]: + result = {"created_accounts": 0, "created_users": 0} + for account_id, user_ids in identities.items(): + created = await self._mutate_account( + account_id, + lambda account_id=account_id, user_ids=user_ids: ( + self._mysql.ensure_trusted_identities({account_id: user_ids}) + ), + lambda: (), + account_wide=True, + ) + result["created_accounts"] += created["created_accounts"] + result["created_users"] += created["created_users"] + return result + + async def begin_deletion( + self, account_id: str, user_id: str | None, **kwargs + ) -> tuple[DeletionRecord, bool]: + return await self._mutate_account( + account_id, + lambda: self._mysql.begin_deletion(account_id, user_id, **kwargs), + lambda: ( + self._account_deletion_key(account_id) + if user_id is None + else self._user_deletion_key(account_id, user_id), + ), + ) + + async def replace_deletion_task( + self, account_id: str, user_id: str | None, **kwargs + ) -> DeletionRecord: + return await self._mutate_account( + account_id, + lambda: self._mysql.replace_deletion_task(account_id, user_id, **kwargs), + lambda: ( + self._account_deletion_key(account_id) + if user_id is None + else self._user_deletion_key(account_id, user_id), + ), + ) + + async def finish_deletion(self, account_id: str, user_id: str | None, task_id: str) -> bool: + return await self._mutate_account( + account_id, + lambda: self._mysql.finish_deletion(account_id, user_id, task_id), + lambda: () if user_id is None else self._user_cache_keys(account_id, user_id), + account_wide=user_id is None, + ) + + async def replace_active_user_api_key( + self, account_id: str, user_id: str, api_key: str + ) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.replace_active_user_api_key(account_id, user_id, api_key), + lambda: ( + (self._user_credential_fence_key(account_id, user_id),) + + self._binding_cache_keys(account_id, api_key, user_id) + ), + ) + + async def revoke_active_api_keys(self, account_id: str, user_id: str | None = None) -> None: + await self._mutate_account( + account_id, + lambda: self._mysql.revoke_active_api_keys(account_id, user_id), + lambda: ( + (self._account_credential_fence_key(account_id),) + if user_id is None + else (self._user_credential_fence_key(account_id, user_id),) + ), + ) + + async def get_user_key_fingerprint(self, account_id: str, user_id: str) -> str | None: + return await self._mysql.get_user_key_fingerprint(account_id, user_id) diff --git a/openviking/server/account_stores/sql/changes/0000_template.sql b/openviking/server/account_stores/sql/changes/0000_template.sql new file mode 100644 index 0000000000..414ec0cd3c --- /dev/null +++ b/openviking/server/account_stores/sql/changes/0000_template.sql @@ -0,0 +1,5 @@ +-- Copy this file as YYYYMMDD_description.sql for each reviewed schema change. +-- Keep changes forward-only. Do not modify init.sql after it is released. +-- MySQL DDL may commit implicitly; review rollback and rollout separately. + +-- ALTER TABLE `ov_accounts` ADD COLUMN example VARCHAR(255) NULL; diff --git a/openviking/server/account_stores/sql/init.sql b/openviking/server/account_stores/sql/init.sql new file mode 100644 index 0000000000..3fae25d302 --- /dev/null +++ b/openviking/server/account_stores/sql/init.sql @@ -0,0 +1,102 @@ +-- OpenViking MySQL account store initial schema. +-- Execute manually before starting a MySQL account-store deployment. +-- Table names are fixed and service_name isolates OpenViking deployments. + +CREATE TABLE `ov_accounts` ( + account_pk BIGINT NOT NULL AUTO_INCREMENT, + service_name VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + account_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + deletion_task_id VARCHAR(64) NULL, + deletion_owner_account_id VARCHAR(255) COLLATE utf8mb4_bin NULL, + deletion_owner_user_id VARCHAR(255) COLLATE utf8mb4_bin NULL, + PRIMARY KEY (account_pk), + CONSTRAINT uq_ov_accounts_account_identity UNIQUE (service_name, account_id), + CONSTRAINT uq_ov_accounts_account_service_pk UNIQUE (service_name, account_pk), + INDEX ix_ov_accounts_account_deletion (service_name, deletion_task_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `ov_accounts_users` ( + user_pk BIGINT NOT NULL AUTO_INCREMENT, + service_name VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + account_pk BIGINT NOT NULL, + user_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + `role` VARCHAR(16) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + deletion_task_id VARCHAR(64) NULL, + deletion_owner_account_id VARCHAR(255) COLLATE utf8mb4_bin NULL, + deletion_owner_user_id VARCHAR(255) COLLATE utf8mb4_bin NULL, + PRIMARY KEY (user_pk), + CONSTRAINT fk_ov_accounts_user_account + FOREIGN KEY (service_name, account_pk) + REFERENCES `ov_accounts` (service_name, account_pk) + ON DELETE CASCADE, + CONSTRAINT uq_ov_accounts_user_identity + UNIQUE (service_name, account_pk, user_id), + CONSTRAINT uq_ov_accounts_user_service_pk UNIQUE (service_name, user_pk), + CONSTRAINT uq_ov_accounts_user_account_pk + UNIQUE (service_name, account_pk, user_pk), + INDEX ix_ov_accounts_user_admin (service_name, account_pk, `role`), + INDEX ix_ov_accounts_user_deletion (service_name, deletion_task_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `ov_accounts_groups` ( + group_pk BIGINT NOT NULL AUTO_INCREMENT, + service_name VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + account_pk BIGINT NOT NULL, + group_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (group_pk), + CONSTRAINT fk_ov_accounts_group_account + FOREIGN KEY (service_name, account_pk) + REFERENCES `ov_accounts` (service_name, account_pk) + ON DELETE CASCADE, + CONSTRAINT uq_ov_accounts_group_identity + UNIQUE (service_name, account_pk, group_id), + CONSTRAINT uq_ov_accounts_group_service_pk UNIQUE (service_name, group_pk), + CONSTRAINT uq_ov_accounts_group_account_pk + UNIQUE (service_name, account_pk, group_pk) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `ov_accounts_group_members` ( + service_name VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + account_pk BIGINT NOT NULL, + group_pk BIGINT NOT NULL, + user_pk BIGINT NOT NULL, + PRIMARY KEY (service_name, account_pk, group_pk, user_pk), + CONSTRAINT fk_ov_accounts_member_group + FOREIGN KEY (service_name, account_pk, group_pk) + REFERENCES `ov_accounts_groups` (service_name, account_pk, group_pk) + ON DELETE CASCADE, + CONSTRAINT fk_ov_accounts_member_user + FOREIGN KEY (service_name, account_pk, user_pk) + REFERENCES `ov_accounts_users` (service_name, account_pk, user_pk) + ON DELETE CASCADE, + INDEX ix_ov_accounts_member_user (service_name, account_pk, user_pk) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `ov_accounts_credentials` ( + credential_id VARCHAR(32) COLLATE ascii_bin NOT NULL, + service_name VARCHAR(255) COLLATE utf8mb4_bin NOT NULL, + user_pk BIGINT NOT NULL, + material TEXT COLLATE ascii_bin NOT NULL, + lookup_digest VARCHAR(64) COLLATE ascii_bin NOT NULL, + prefix VARCHAR(8) COLLATE ascii_bin NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 1, + sequence BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + revoked_at DATETIME(6) NULL, + active_user_pk BIGINT NULL, + PRIMARY KEY (credential_id), + CONSTRAINT fk_ov_accounts_credential_user + FOREIGN KEY (service_name, user_pk) + REFERENCES `ov_accounts_users` (service_name, user_pk) + ON DELETE CASCADE, + CONSTRAINT uq_ov_accounts_active_credential + UNIQUE (service_name, active_user_pk), + INDEX ix_ov_accounts_credential_lookup + (service_name, lookup_digest, revoked, user_pk), + INDEX ix_ov_accounts_credential_history + (service_name, user_pk, revoked, sequence) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/openviking/server/api_keys/legacy.py b/openviking/server/api_keys/legacy.py index e8407e6fc7..a18647458c 100644 --- a/openviking/server/api_keys/legacy.py +++ b/openviking/server/api_keys/legacy.py @@ -15,6 +15,7 @@ from argon2.exceptions import VerificationError from typing_extensions import deprecated +from openviking.core.identifiers import validate_identifier_length from openviking.pyagfs import AGFSAlreadyExistsError, AGFSNotFoundError, AsyncAGFSClient from openviking.pyagfs.async_client import fs_ctx_from_agfs_path from openviking.server.account_stores.base import AccountStore @@ -468,6 +469,8 @@ async def _create_account_identity( validate_file_account_id(account_id) if error := validate_user_id(admin_user_id): raise InvalidArgumentError(error) + if error := validate_identifier_length(admin_user_id, "admin_user_id"): + raise InvalidArgumentError(error) if account_id in self._accounts: raise AlreadyExistsError(account_id, "account") @@ -525,6 +528,8 @@ async def _create_user_identity( resolved_role = validate_account_user_role(role) if error := validate_user_id(user_id): raise InvalidArgumentError(error) + if error := validate_identifier_length(user_id, "user_id"): + raise InvalidArgumentError(error) self._ensure_account_active(account_id) account = self._accounts.get(account_id) if account is None: @@ -644,6 +649,11 @@ async def _ensure_trusted_identities_unlocked( error = validate_user_id(user_id) if error: raise InvalidArgumentError(error) + account = self._accounts.get(account_id) + if account is None or user_id not in account.users: + error = validate_identifier_length(user_id, "user_id") + if error: + raise InvalidArgumentError(error) created_accounts = 0 created_users = 0 diff --git a/openviking/server/app.py b/openviking/server/app.py index 5c0a8c6278..f74e3078f4 100644 --- a/openviking/server/app.py +++ b/openviking/server/app.py @@ -452,6 +452,7 @@ async def _oauth_gc_loop(store) -> None: # noqa: ANN001 ) app.state.config = config + app.state.service_name = get_openviking_config().service_name app.state.server_config_overrides = server_overrides or {} from openviking.server.restart import RestartController diff --git a/openviking/server/auth/plugins/api_key.py b/openviking/server/auth/plugins/api_key.py index 75d44bb926..48345216d2 100644 --- a/openviking/server/auth/plugins/api_key.py +++ b/openviking/server/auth/plugins/api_key.py @@ -181,6 +181,7 @@ async def initialize(self, app, service, config) -> None: root_key=config.root_api_key, viking_fs=service.viking_fs, api_key_hashing_enabled=config.api_key_hashing_enabled, + service_name=app.state.service_name, account_store_provider=config.account_store.provider, account_store_params=config.account_store.params, account_store_watch_enabled=config.api_key_watch_enabled, diff --git a/openviking/server/auth/plugins/trusted.py b/openviking/server/auth/plugins/trusted.py index 4b87d08293..c2c7f80caf 100644 --- a/openviking/server/auth/plugins/trusted.py +++ b/openviking/server/auth/plugins/trusted.py @@ -12,7 +12,11 @@ from fastapi import Request -from openviking.core.identifiers import validate_account_id, validate_user_id +from openviking.core.identifiers import ( + validate_account_id, + validate_identifier_length, + validate_user_id, +) from openviking.server.api_keys import APIKeyManager from openviking.server.api_keys.legacy import validate_file_account_id from openviking.server.auth.plugin import AuthPlugin @@ -204,6 +208,7 @@ async def initialize(self, app, service, config) -> None: root_key=config.root_api_key, viking_fs=service.viking_fs, api_key_hashing_enabled=config.api_key_hashing_enabled, + service_name=app.state.service_name, account_store_provider=config.account_store.provider, account_store_params=config.account_store.params, account_store_watch_enabled=config.api_key_watch_enabled, @@ -232,6 +237,8 @@ async def _queue_trusted_identity(self, account_id: str, user_id: str) -> None: return if await manager.get_user(account_id, user_id) is not None: return + if length_error := validate_identifier_length(user_id, "user_id"): + raise InvalidArgumentError(length_error) if user_id in self._in_flight.get(account_id, set()): return if user_id in self._pending.get(account_id, set()): diff --git a/openviking/server/config.py b/openviking/server/config.py index 4f07f587a9..7759acd0fd 100644 --- a/openviking/server/config.py +++ b/openviking/server/config.py @@ -313,8 +313,11 @@ class AccountStoreConfig(BaseModel): @classmethod def _validate_provider(cls, value: str) -> str: normalized = value.strip().lower() - if not normalized: - raise ValueError("account_store.provider must not be empty") + supported = {"file", "mysql", "redis_mysql"} + if normalized not in supported: + raise ValueError( + "account_store.provider must be one of: " + ", ".join(sorted(supported)) + ) return normalized diff --git a/openviking/server/store_assembly.py b/openviking/server/store_assembly.py index 64741d1894..4fbbd0dd7a 100644 --- a/openviking/server/store_assembly.py +++ b/openviking/server/store_assembly.py @@ -20,6 +20,7 @@ def build_api_key_manager( viking_fs: VikingFS | None, *, api_key_hashing_enabled: bool = False, + service_name: str | None = None, account_store_provider: str = "file", account_store_params: dict[str, object] | None = None, account_store_watch_enabled: bool = False, @@ -34,6 +35,7 @@ def build_api_key_manager( store = build_account_store( viking_fs=viking_fs, api_key_hashing_enabled=api_key_hashing_enabled, + service_name=service_name, account_store_provider=provider, account_store_params=account_store_params, account_store_watch_enabled=account_store_watch_enabled, @@ -47,6 +49,7 @@ def build_account_store( *, viking_fs: VikingFS | None, api_key_hashing_enabled: bool, + service_name: str | None = None, account_store_provider: str, account_store_params: dict[str, object] | None, account_store_watch_enabled: bool, @@ -58,6 +61,10 @@ def build_account_store( return account_store provider = account_store_provider.strip().lower() + if provider in {"mysql", "redis_mysql"} and service_name is None: + raise InvalidArgumentError( + f"service_name is required for the {provider} account store provider" + ) if provider == "file": file_system = cast(VikingFS, viking_fs) return FileStore( @@ -67,7 +74,31 @@ def build_account_store( watch_enabled=account_store_watch_enabled, watch_interval_seconds=account_store_watch_interval_seconds, ) + if provider == "mysql": + try: + from openviking.server.account_stores.mysql import MySQLAccountStore + except ImportError: + raise RuntimeError( + "MySQL account storage requires openviking[mysql]" + ) from None + return MySQLAccountStore( + service_name=service_name, + params=account_store_params, + ) + if provider == "redis_mysql": + try: + from openviking.server.account_stores.redis_mysql import ( + RedisMySQLAccountStore, + ) + except ImportError: + raise RuntimeError( + "Redis MySQL account storage requires openviking[redis-mysql]" + ) from None + return RedisMySQLAccountStore( + service_name=service_name, + params=account_store_params, + ) raise InvalidArgumentError( f"Unknown account store provider '{account_store_provider}'. " - "Available providers: file" + "Available providers: file, mysql, redis_mysql" ) diff --git a/pyproject.toml b/pyproject.toml index 3b8d764715..57f65f8181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,18 @@ gateway-fast = [ "uvloop>=0.21; sys_platform != 'win32'", "httptools>=0.6", ] +mysql = [ + "aiomysql>=0.2.0,<1", + "SQLAlchemy[asyncio]>=2.0.54,<2.1", +] +redis-mysql = [ + "aiomysql>=0.2.0,<1", + "redis>=5.0,<7", + "SQLAlchemy[asyncio]>=2.0.54,<2.1", +] test = [ + "SQLAlchemy[asyncio]>=2.0,<2.1", + "redis>=5.0,<7", "pytest>=7.0.0", "pytest-asyncio>=0.21.0", "pytest-xdist>=3.5.0", diff --git a/tests/server/test_account_services.py b/tests/server/test_account_services.py index 54a2af62db..447ec7bacf 100644 --- a/tests/server/test_account_services.py +++ b/tests/server/test_account_services.py @@ -3,11 +3,16 @@ import asyncio import json +import os import threading from types import SimpleNamespace +from uuid import uuid4 import pytest +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import create_async_engine +from openviking.server.account_stores.mysql_schema import Account, Base, Credential from openviking.server.api_keys import APIKeyManager from openviking.server.api_keys.legacy import FileStore from openviking.server.identity import Role @@ -16,6 +21,7 @@ FailedPreconditionError, InvalidArgumentError, NotFoundError, + UnauthenticatedError, ) from tests.server.account_store_fakes import InMemoryAGFS @@ -45,6 +51,10 @@ async def test_legacy_user_without_role_defaults_to_user_during_authentication(a manager = build_api_key_manager("root", SimpleNamespace(agfs=agfs)) await manager.load() try: + assert await manager.ensure_trusted_identities({account_id: {user_id}}) == { + "created_accounts": 0, + "created_users": 0, + } identity = await manager.resolve_identity("legacy-key") assert (identity.account_id, identity.user_id, identity.role) == ( account_id, @@ -103,7 +113,7 @@ async def test_file_account_creation_respects_path_component_limit(issue_user_ke @pytest.mark.parametrize("issue_user_keys", [True, False], ids=["api-key", "trusted"]) @pytest.mark.parametrize("length", [255, 256, 1024]) -async def test_file_account_creation_allows_shortened_admin_user_component( +async def test_file_account_creation_respects_admin_user_id_limit( issue_user_keys, length, ): @@ -115,19 +125,23 @@ async def test_file_account_creation_allows_shortened_admin_user_component( account_id = "acme" admin_user_id = "u" * length try: - key = await manager.create_account(account_id, admin_user_id) - assert await manager.get_registered_user_role(account_id, admin_user_id) == Role.ADMIN - if issue_user_keys: - assert (await manager.resolve_identity(key)).user_id == admin_user_id + if length <= 255: + key = await manager.create_account(account_id, admin_user_id) + assert await manager.get_registered_user_role(account_id, admin_user_id) == Role.ADMIN + if issue_user_keys: + assert (await manager.resolve_identity(key)).user_id == admin_user_id else: - assert key is None + with pytest.raises(InvalidArgumentError, match="user_id.*255"): + await manager.create_account(account_id, admin_user_id) + assert await manager.get_account(account_id) is None + assert await manager.get_user(account_id, admin_user_id) is None finally: await manager.close() @pytest.mark.parametrize("issue_user_keys", [True, False], ids=["api-key", "trusted"]) @pytest.mark.parametrize("length", [255, 256, 1024]) -async def test_file_user_creation_allows_shortened_user_component(issue_user_keys, length): +async def test_file_user_creation_respects_user_id_limit(issue_user_keys, length): agfs = InMemoryAGFS() manager = build_api_key_manager( "root", SimpleNamespace(agfs=agfs), issue_user_keys=issue_user_keys @@ -136,12 +150,17 @@ async def test_file_user_creation_allows_shortened_user_component(issue_user_key user_id = "u" * length try: await manager.create_account("acme", "alice") - key = await manager.register_user("acme", user_id) - assert await manager.get_registered_user_role("acme", user_id) == Role.USER - if issue_user_keys: - assert (await manager.resolve_identity(key)).user_id == user_id + if length <= 255: + key = await manager.register_user("acme", user_id) + assert await manager.get_registered_user_role("acme", user_id) == Role.USER + if issue_user_keys: + assert (await manager.resolve_identity(key)).user_id == user_id + else: + assert key is None else: - assert key is None + with pytest.raises(InvalidArgumentError, match="user_id.*255"): + await manager.register_user("acme", user_id) + assert await manager.get_user("acme", user_id) is None finally: await manager.close() @@ -221,16 +240,108 @@ async def test_regenerate_key_preserves_management_not_found_errors(): await manager.regenerate_key("acme", "missing") -async def test_file_store_allows_only_one_active_credential_per_user(): - store = _file_store() - await store.load() - await store.create_account_with_api_key("acme", "alice", "initial-key") - - await store.replace_active_user_api_key("acme", "alice", "first-key") - await store.replace_active_user_api_key("acme", "alice", "second-key") - assert await store.verify_api_key("first-key") is None - active = await store.verify_api_key("second-key") - assert active == ("acme", "alice") +async def test_distributed_credentials_are_shared_and_service_scoped(): + """Warm caches must reject rotated keys without crossing service boundaries.""" + raw_mysql = os.environ.get("OV_TEST_MYSQL") + redis_url = os.environ.get("OV_TEST_REDIS") + if not raw_mysql or not redis_url: + pytest.skip("Set OV_TEST_MYSQL and OV_TEST_REDIS for the distributed credential contract") + + service_name = f"test-{uuid4().hex}" + isolated_service_name = f"test-{uuid4().hex}" + key_prefix = f"ov:test:{uuid4().hex}" + params = { + "mysql": json.loads(raw_mysql), + "cache": { + "provider": "redis", + "params": { + "mode": "standalone", + "endpoints": [redis_url], + "key_prefix": key_prefix, + }, + }, + } + writer = build_api_key_manager( + "root", + None, + service_name=service_name, + account_store_provider="redis_mysql", + account_store_params=params, + ) + reader = build_api_key_manager( + "root", + None, + service_name=service_name, + account_store_provider="redis_mysql", + account_store_params=params, + ) + isolated = build_api_key_manager( + "root", + None, + service_name=isolated_service_name, + account_store_provider="redis_mysql", + account_store_params=params, + ) + mysql = writer._store._mysql + engine = create_async_engine(mysql._url, **mysql._engine_options) + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await writer.load() + await reader.load() + await isolated.load() + + old_key = await writer.create_account("acme", "alice") + isolated_key = await isolated.create_account("acme", "alice") + assert (await reader.resolve_identity(old_key)).role == Role.ADMIN + assert (await isolated.resolve_identity(isolated_key)).role == Role.ADMIN + with pytest.raises(UnauthenticatedError): + await isolated.resolve_identity(old_key) + with pytest.raises(UnauthenticatedError): + await reader.resolve_identity(isolated_key) + + # Repeated rotation must bound history and invalidate an already cached key. + for _ in range(12): + active_key = await writer.regenerate_key("acme", "alice") + with pytest.raises(UnauthenticatedError): + await reader.resolve_identity(old_key) + assert (await reader.resolve_identity(active_key)).user_id == "alice" + assert (await isolated.resolve_identity(isolated_key)).role == Role.ADMIN + async with engine.connect() as connection: + assert ( + await connection.scalar( + select(func.count()) + .select_from(Credential) + .where(Credential.service_name == service_name) + ) + == 11 + ) + assert ( + await connection.scalar( + select(func.count()) + .select_from(Credential) + .where(Credential.service_name == service_name, Credential.revoked.is_(False)) + ) + == 1 + ) + finally: + try: + redis_client = writer._store._redis + if redis_client is not None: + keys = [key async for key in redis_client.scan_iter(match=f"{key_prefix}:*")] + if keys: + await redis_client.delete(*keys) + async with engine.begin() as connection: + await connection.execute( + delete(Account).where( + Account.service_name.in_([service_name, isolated_service_name]) + ) + ) + finally: + await isolated.close() + await reader.close() + await writer.close() + await engine.dispose() async def test_file_management_user_read_refreshes_only_explicitly(): diff --git a/tests/server/test_auth.py b/tests/server/test_auth.py index dfe92bb2b7..a0cb291b29 100644 --- a/tests/server/test_auth.py +++ b/tests/server/test_auth.py @@ -55,6 +55,7 @@ def write(self, path: str, data): return "OK" def read(self, path: str, offset: int = 0, size: int = -1, stream: bool = False): + del stream if path not in self.files: raise FileNotFoundError(path) data = self.files[path] @@ -1006,6 +1007,7 @@ async def test_trusted_identity_registration_batches_data_plane_identity(auth_se for restarted in (False, True): app = FastAPI() app.state.config = config + app.state.service_name = "openviking-server" plugin = TrustedAuthPlugin() await plugin.initialize(app, auth_service, config) try: @@ -1043,6 +1045,7 @@ async def test_invalid_trusted_identity_does_not_block_later_registration( ) app = FastAPI() app.state.config = config + app.state.service_name = "openviking-server" plugin = TrustedAuthPlugin() await plugin.initialize(app, auth_service, config) try: @@ -1095,6 +1098,7 @@ async def test_trusted_identity_registration_skips_identity_loaded_from_registry config = ServerConfig(auth_mode="trusted", root_api_key=ROOT_KEY) app = FastAPI() app.state.config = config + app.state.service_name = "openviking-server" first_plugin = TrustedAuthPlugin() await first_plugin.initialize(app, auth_service, config) try: @@ -1104,6 +1108,7 @@ async def test_trusted_identity_registration_skips_identity_loaded_from_registry restarted_app = FastAPI() restarted_app.state.config = config + restarted_app.state.service_name = "openviking-server" restarted_plugin = TrustedAuthPlugin() await restarted_plugin.initialize(restarted_app, auth_service, config) try: @@ -1142,6 +1147,7 @@ async def test_trusted_registration_respects_configuration_and_route(auth_servic ) app = FastAPI() app.state.config = config + app.state.service_name = "openviking-server" plugin = TrustedAuthPlugin() await plugin.initialize(app, auth_service, config) try: @@ -1182,6 +1188,7 @@ async def test_trusted_identity_registration_retries_failed_batch_without_exceed ) app = FastAPI() app.state.config = config + app.state.service_name = "openviking-server" plugin = TrustedAuthPlugin() await plugin.initialize(app, auth_service, config) try: diff --git a/tests/server/test_identity_auth.py b/tests/server/test_identity_auth.py index d2475c0735..dc66b7b7b2 100644 --- a/tests/server/test_identity_auth.py +++ b/tests/server/test_identity_auth.py @@ -124,7 +124,7 @@ async def test_trusted_explicit_user_role_with_root_key(runtime): assert identity.role == Role.USER -async def test_oversized_trusted_account_does_not_block_valid_registration(runtime): +async def test_oversized_trusted_identity_does_not_block_valid_registration(runtime): request = await make_request(runtime, "trusted", trusted_flush_interval=300) plugin = request.app.state.auth_plugin account_id = "a" * 255 @@ -140,14 +140,15 @@ async def test_oversized_trusted_account_does_not_block_valid_registration(runti x_openviking_user="bob", ) - long_user_id = "u" * 1024 - await plugin.resolve_identity( - request, api_key="root", x_openviking_account="acme", x_openviking_user=long_user_id - ) + long_user_id = "u" * 256 + with pytest.raises(InvalidArgumentError, match="user_id.*255"): + await plugin.resolve_identity( + request, api_key="root", x_openviking_account="acme", x_openviking_user=long_user_id + ) await plugin.flush_trusted_identities() assert await runtime.get_registered_user_role(account_id, "alice") == Role.USER - assert await runtime.get_registered_user_role("acme", long_user_id) == Role.USER + assert await runtime.get_registered_user_role("acme", long_user_id) is None async def test_trusted_registration_preserves_existing_oversized_account(): diff --git a/tests/server/test_store_assembly.py b/tests/server/test_store_assembly.py index fbc3925b59..136707a2c2 100644 --- a/tests/server/test_store_assembly.py +++ b/tests/server/test_store_assembly.py @@ -1,10 +1,12 @@ # Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. # SPDX-License-Identifier: AGPL-3.0 +import ssl from types import SimpleNamespace import pytest +from openviking.server.account_stores.redis_client import create_redis_client from openviking.server.api_keys.legacy import FileStore from openviking.server.config import AccountStoreConfig from openviking.server.store_assembly import ( @@ -15,7 +17,9 @@ from tests.server.account_store_fakes import InMemoryAGFS -def test_unknown_account_store_is_rejected(): +async def test_account_store_connection_configuration(): + with pytest.raises(ValueError, match="account_store.provider must be one of"): + AccountStoreConfig(provider="missing") with pytest.raises(InvalidArgumentError, match="Unknown account store provider"): build_account_store( viking_fs=SimpleNamespace(agfs=InMemoryAGFS()), @@ -26,6 +30,16 @@ def test_unknown_account_store_is_rejected(): account_store_watch_interval_seconds=30, ) + redis_connection = pytest.importorskip("redis.asyncio.connection") + handle = create_redis_client({"mode": "standalone", "endpoints": ["rediss://127.0.0.1:6379"]}) + try: + # Constructing only the pool would miss unsupported connection arguments. + connection = handle.client.connection_pool.make_connection() + assert isinstance(connection, redis_connection.SSLConnection) + assert connection.ssl_context.get().verify_mode == ssl.CERT_REQUIRED + finally: + await handle.close() + @pytest.mark.parametrize("injected", [False, True]) async def test_assembled_manager_supports_account_authentication(injected): diff --git a/uv.lock b/uv.lock index 4892f745de..d88b650c02 100644 --- a/uv.lock +++ b/uv.lock @@ -160,6 +160,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] +[[package]] +name = "aiomysql" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymysql" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/e0/302aeffe8d90853556f47f3106b89c16cc2ec2a4d269bdfd82e3f4ae12cc/aiomysql-0.3.2.tar.gz", hash = "sha256:72d15ef5cfc34c03468eb41e1b90adb9fd9347b0b589114bd23ead569a02ac1a", size = 108311, upload-time = "2025-10-22T00:15:21.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/af/aae0153c3e28712adaf462328f6c7a3c196a1c1c27b491de4377dd3e6b52/aiomysql-0.3.2-py3-none-any.whl", hash = "sha256:c82c5ba04137d7afd5c693a258bea8ead2aad77101668044143a991e04632eb2", size = 71834, upload-time = "2025-10-22T00:15:15.905Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -1480,6 +1492,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -1487,6 +1500,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -1495,6 +1509,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -1503,6 +1518,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -1511,6 +1527,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -1519,6 +1536,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -3651,12 +3669,21 @@ gemini-async = [ local-embed = [ { name = "llama-cpp-python" }, ] +mysql = [ + { name = "aiomysql" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] ocr = [ { name = "pytesseract" }, ] opengauss = [ { name = "psycopg2-binary" }, ] +redis-mysql = [ + { name = "aiomysql" }, + { name = "redis" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] test = [ { name = "boto3" }, { name = "datasets" }, @@ -3669,6 +3696,8 @@ test = [ { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ragas" }, + { name = "redis" }, + { name = "sqlalchemy", extra = ["asyncio"] }, ] [package.dev-dependencies] @@ -3680,6 +3709,8 @@ dev = [ requires-dist = [ { name = "agent-sandbox", marker = "extra == 'bot'", specifier = ">=0.0.23" }, { name = "aiohttp", specifier = ">=3.13.3" }, + { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0,<1" }, + { name = "aiomysql", marker = "extra == 'redis-mysql'", specifier = ">=0.2.0,<1" }, { name = "anyio", marker = "extra == 'gemini-async'", specifier = ">=4.0.0" }, { name = "apscheduler", specifier = ">=3.11.0" }, { name = "argon2-cffi", specifier = ">=23.0.0" }, @@ -3773,6 +3804,8 @@ requires-dist = [ { name = "ragas", marker = "extra == 'test'", specifier = ">=0.1.0" }, { name = "rapidfuzz", specifier = ">=3.14,<4" }, { name = "readability-lxml", marker = "extra == 'bot'", specifier = ">=0.8.0" }, + { name = "redis", marker = "extra == 'redis-mysql'", specifier = ">=5.0,<7" }, + { name = "redis", marker = "extra == 'test'", specifier = ">=5.0,<7" }, { name = "requests", specifier = ">=2.33.0" }, { name = "rich", marker = "extra == 'bot'", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, @@ -3784,6 +3817,9 @@ requires-dist = [ { name = "socksio", marker = "extra == 'bot'", specifier = ">=1.0.0" }, { name = "sphinx", marker = "extra == 'doc'", specifier = ">=7.0.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'doc'", specifier = ">=1.3.0" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'mysql'", specifier = ">=2.0.54,<2.1" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'redis-mysql'", specifier = ">=2.0.54,<2.1" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'test'", specifier = ">=2.0,<2.1" }, { name = "tabulate", specifier = ">=0.9.0" }, { name = "tavily-python", marker = "extra == 'bot'", specifier = ">=0.5.0" }, { name = "tiktoken", marker = "extra == 'benchmark'", specifier = ">=0.5.0" }, @@ -3812,7 +3848,7 @@ requires-dist = [ { name = "wheel", marker = "extra == 'build'" }, { name = "xxhash", specifier = ">=3.0.0" }, ] -provides-extras = ["gateway-fast", "test", "auth", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "benchmark", "local-embed", "opengauss"] +provides-extras = ["gateway-fast", "mysql", "redis-mysql", "test", "auth", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "benchmark", "local-embed", "opengauss"] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=9.0.2" }] @@ -4848,6 +4884,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pymysql" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/d4/c15b459e25a23767d2f4065ef40968920320f04e302889574310c21c96a3/pymysql-1.2.3.tar.gz", hash = "sha256:d5b288529782e536ae171866df3ca9dc4f6cbfb3cc2f18e6f837fbb90dbc262b", size = 50629, upload-time = "2026-09-17T12:22:49.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/4b/0a906d8184f011ff8dbd4722743783867589b33269d2c5fff238d636fdcb/pymysql-1.2.3-py3-none-any.whl", hash = "sha256:14f1c68e2ed859243ae5ca41ffbe677027fc46bc136a9f0be8a4e928e5e7415a", size = 46740, upload-time = "2026-09-17T12:22:47.826Z" }, +] + [[package]] name = "pyopenssl" version = "26.3.0" @@ -5481,6 +5526,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/75/2cc58965097e351415af420be81c4665cf80da52a17ef43c01ffbe2caf91/readability_lxml-0.8.4.1-py3-none-any.whl", hash = "sha256:874c0cea22c3bf2b78c7f8df831bfaad3c0a89b7301d45a188db581652b4b465", size = 19912, upload-time = "2025-05-03T21:11:43.993Z" }, ] +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -6213,62 +6270,62 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.48" +version = "2.0.54" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, - { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, - { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, - { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, - { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, - { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, - { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, - { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, - { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, - { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/271aa905cf2964f841371a97f3e63ab692bf51b4423d0491e67bc7f64037/sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b", size = 9969559, upload-time = "2026-09-15T21:06:57.337Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/2f/7a427d7e10bc5f285d41fa1cadf46031ffc6c84f35a9d6e19737587a4eb0/sqlalchemy-2.0.54-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24ae093dec196ba37fc2beb0316de53e7871d3d246a50faecbbb53034e41ded2", size = 2189267, upload-time = "2026-09-15T22:11:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/80122de187c0f80cb47addce97609160004a8605010afdd815e608fcf003/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cc6532f930c27974e9239e5ce5abebe7600ba9807cea4fcf42f1b6cab18fe7", size = 3358362, upload-time = "2026-09-15T22:33:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/c8/93/9d26b109f5472017bf6e7a1b9dac12ac5f6165ba37a297834d0690c42cf3/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e7a76d5dce712ce50435d0f97181eb955ec27d138c004176f01282e063bac52", size = 3357692, upload-time = "2026-09-15T22:33:14.781Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d4/63e800b45bd4fa59cb4b14c013130e0aa92c7a019a0b8bf68115643bf32b/sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f5c09090b1a7c4d389d1431f820931e8df318f82caafc53f9a72c872fef467c5", size = 3308258, upload-time = "2026-09-15T22:33:38.469Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fe/6c96fe018b9db15476b46619228dc0749cdd9b4dc8baab89aca59205b605/sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:762cfe4d340c56368256d936a98b620a9a5650e49c1c84eba51d6edd17ffefb2", size = 3336734, upload-time = "2026-09-15T22:33:16.533Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f1/8d225d6b77863ae83ba51e913fe5954bcf58b49dd54229f0b5ba0bc983f0/sqlalchemy-2.0.54-cp310-cp310-win32.whl", hash = "sha256:6b6d4e601c4f6d85e99bb3416107cc9418c5603ca73d4ee0f5f8d79c2a1ed9e8", size = 2144294, upload-time = "2026-09-15T22:41:20.424Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f6/4247fdd087bf1b39be90930bb2b276a1c55041540628c8736512b4242669/sqlalchemy-2.0.54-cp310-cp310-win_amd64.whl", hash = "sha256:03cbf8d9a67da618bd65500a5eb3ddac89caf4c61e99b2f03fa4a1952a0725a9", size = 2170318, upload-time = "2026-09-15T22:41:21.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/7339b18ef05c03335a32f077e450c8040eab0c952c2964e32cd889eafc22/sqlalchemy-2.0.54-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d03084f3352dd92048cb19c71d90f116d076c9c7937e0ebc7752c4685de6d38", size = 2187776, upload-time = "2026-09-15T22:32:35.535Z" }, + { url = "https://files.pythonhosted.org/packages/9b/bb/3b6f5f3f51582d63af4c88b1e70da7888e936dee6b86a237b7633fb3f216/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92622fbbda1b1fe1632f3402a6e516a93c0e41d9158839c6b3dfb12117f26b72", size = 3428952, upload-time = "2026-09-15T22:40:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/ffd6b59795ebec81d582ad866f71ec7c73d356768e0442c6a07bba61c40c/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5800ddea045c2c860ef1d359a07a3066c7c0c426f45e3abc3874e116cb3c6937", size = 3428566, upload-time = "2026-09-15T22:35:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/63/ff/1f0bffb0653b30711e2445a48968eff9412b4e5bf0870f1e72e8a69595ef/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019abef05a4b5eafc8eae6fb483167fa28a4dbe5f518d577b744f31a5276a37", size = 3377697, upload-time = "2026-09-15T22:40:16.494Z" }, + { url = "https://files.pythonhosted.org/packages/97/76/774906be0d41cc4a14ecfbc015112fb7d0a3f1c3393f2c179d10388f8f0b/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b67749f7da3985a529cefbb1474783cb91ef44371cb9713630bade3de908760d", size = 3402966, upload-time = "2026-09-15T22:35:35.726Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bb/1d7fe50ace612bf342d3440d8b09544746949120326c3dc487cfc2592fba/sqlalchemy-2.0.54-cp311-cp311-win32.whl", hash = "sha256:2f61a70b3b82e2ec7ad6a4f2301422b9ca93ff06917983e41317bcae878bddf6", size = 2142297, upload-time = "2026-09-15T21:25:19.917Z" }, + { url = "https://files.pythonhosted.org/packages/d6/49/18fe80f64b1f6bcd422e649e40a78031f8624b2fde22ca54e950dfa6e25a/sqlalchemy-2.0.54-cp311-cp311-win_amd64.whl", hash = "sha256:1d887fbd5d248e250807bd801e697fc73e3b44866ce5f093dbc90512e75bde25", size = 2169222, upload-time = "2026-09-15T21:25:21.114Z" }, + { url = "https://files.pythonhosted.org/packages/c6/30/75504fd1d70458000e85a3e772333dd0fbf80254b0dc4d41c99922e4d112/sqlalchemy-2.0.54-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffba7eb2d67c7505e82a0902aa854d8824b74c28a183820d6a8bd3cfd0f812c2", size = 2187596, upload-time = "2026-09-15T22:32:37.031Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3d/5dfbb9528a391186a99986daecc5cbe003f34408dee91db8f5cdcf917040/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cae7210fea9899e0bf35c1f1ae55d3ddd9c6d47cae8b6b43d945afa79dd65b", size = 3448745, upload-time = "2026-09-15T22:40:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/d9/93/34fdc4a4faced77037a6b3ba1db1acd92e9bd12c21e229b327edd1d3e881/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68d994e9b0d0423a02a20039631fa6fcbb7fa829a992f7605025774940305d19", size = 3466644, upload-time = "2026-09-15T22:35:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/66/68/4beab40ae60ac3d679dbbb46bc0d2bb277013264a0bde37ecaf4b6780e98/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3de32cc6721eb42c3aad35bcfb244bb7a18f66c00f3582aae6281d6287a339b5", size = 3401519, upload-time = "2026-09-15T22:40:19.907Z" }, + { url = "https://files.pythonhosted.org/packages/e5/df/a24757e3249b1c7c1c5f0317666d16a8ab901ba239819ecef29eb4ed172e/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d31a2bc06a854ee52dd86b455be4df7c750b28817e2d1b884e31fff126c4fd7b", size = 3432590, upload-time = "2026-09-15T22:35:39.434Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0c/69559e3d90d200fbfe9c44aff39a9ff4e506b1a3e9557a203d69df721244/sqlalchemy-2.0.54-cp312-cp312-win32.whl", hash = "sha256:32de6deded25e8b9b11d07428d496ff24dfbc882b8e990c177266948cb5f3d9e", size = 2140825, upload-time = "2026-09-15T21:25:22.444Z" }, + { url = "https://files.pythonhosted.org/packages/d9/10/4a0f7113664c2877906db143709c52ddbd28a88e4d51f0b520bc30048ba7/sqlalchemy-2.0.54-cp312-cp312-win_amd64.whl", hash = "sha256:d65f8ca742ef1e1e14bc417ef59dc2ddf207a7b66b30cfdc6152447314e030cf", size = 2170005, upload-time = "2026-09-15T21:25:23.885Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/021ccd7a838425f52e67c02edc541e8d53be8a27a103e684e14b18aac17b/sqlalchemy-2.0.54-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b374e3bc91e246a942592a98ba6a23be76fff21358b00546ac8c0ebc0fd0e00b", size = 2184259, upload-time = "2026-09-15T22:28:59.153Z" }, + { url = "https://files.pythonhosted.org/packages/74/8f/f95de908a4af7ac3a6925cdcb837a93274f608925a0fbc786df38811bccf/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d5458672a6f72db2c087f4a5098b3c8503ea0254186ff29205d63afa9401a4", size = 3398792, upload-time = "2026-09-15T22:29:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/84/26/bd327a1a6be223e438c98ac8faf299307d83797225a81f3b209607a9fd98/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cad78d04254967bdbcccbed5e631d88fe4868530946ab0929aa45e9032849518", size = 3417562, upload-time = "2026-09-15T22:40:32.637Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b1/f13a8fe8e8e167d8e65484b91b827897b8a5ca1efd7ba6e87a5f63412fed/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:48611087a75d26d798003645c688c7d3cfc26b89dbe4a2c568d6b378d330deae", size = 3349138, upload-time = "2026-09-15T22:29:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/5fd58f03281a74c57f02f509433e1be58a79d9f5b0e14020e665e0dcb614/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d6adf80277372a89910a0f3ccfe960b846d279dc55b366dd5c5ec07f41c84758", size = 3382943, upload-time = "2026-09-15T22:40:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/4b/73/e29fa88dfc809857a55e6523911d345d00cab4adf87032c21ade8874223c/sqlalchemy-2.0.54-cp313-cp313-win32.whl", hash = "sha256:264460333ed0b177cbb1956355d0ee4e0cab83fb415c934ce12a25db2e7be39c", size = 2138931, upload-time = "2026-09-15T22:42:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/89/9d/885e7491836f3dad368c74f0d5b93551d37b0ec66c9e8753240ee988308f/sqlalchemy-2.0.54-cp313-cp313-win_amd64.whl", hash = "sha256:cf89e92bf0d4204a6afcc17af27b9271ed9c7e34e17d6f80c085d431ea4a1747", size = 2166496, upload-time = "2026-09-15T22:42:53.603Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c0/4a6503c9d22d6d00a5631082ab1484222ecf7d573db791e0f53161bf7745/sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0", size = 2185899, upload-time = "2026-09-15T22:29:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/f4424f618bd1f373761a32a821d53ce2c257350e9894bae9b968cb03d8fd/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139", size = 3394763, upload-time = "2026-09-15T22:29:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/59/d2/7f0c77f8e042cb5f28275fea29c3080b4ac6fd4b3fdd7f59ff1ef3e28c11/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e", size = 3402800, upload-time = "2026-09-15T22:40:36.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/32/3eaa930bcf71d17a72587081d2706a5fb97ab3f11a7e0fb838f581f7cff1/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912", size = 3341469, upload-time = "2026-09-15T22:29:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/eb/cc/cddb6cbd4408e5c55b3bf722be26b9d3b54d42509f901b7ecc15debd3d1f/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615", size = 3373232, upload-time = "2026-09-15T22:40:39.181Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/9c2aa5efc642b7f3b985d13565cd1a5e78856e079ef3022796fea5180498/sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44", size = 2142018, upload-time = "2026-09-15T22:42:55.118Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0b/3594f1f51769feb3022d686135dc5d8682a12345ed15ae61d0c0ca42cbee/sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835", size = 2169220, upload-time = "2026-09-15T22:42:56.727Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a5/c211a9a7af83222509519407e16a4db760c6df3d03be69ebc5414d465321/sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a", size = 2208458, upload-time = "2026-09-15T22:30:05.718Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2e/490ad7b3731116cb48ba170f7722eaa99a89707193e54389ca84b7ad55af/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77", size = 3660585, upload-time = "2026-09-15T22:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/eb/25/15dfe6814847eeda773bd58ab6cf42a94b0176e5cf25a578fc1165777160/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd", size = 3624442, upload-time = "2026-09-15T22:36:38.377Z" }, + { url = "https://files.pythonhosted.org/packages/aa/19/724d0a6a2fb2a86ff2d6008e581c258f722d9b7d8e52adc7b79085febdd4/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f", size = 3562972, upload-time = "2026-09-15T22:36:08.581Z" }, + { url = "https://files.pythonhosted.org/packages/49/bb/9df1bd81c2f2d000cf5e7a1b1a9b331468a3aab939ad983355e701fa42b2/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb", size = 3576479, upload-time = "2026-09-15T22:36:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/b5775465d3b89061d7c46057c31c56ff8fb6c509550b2b0c6570ffc248b3/sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51", size = 2174794, upload-time = "2026-09-15T22:31:45.925Z" }, + { url = "https://files.pythonhosted.org/packages/77/f8/296c2e46b4ccd3f29b00b954ef2f195dde32f98e352ed21de1d292cedc0d/sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970", size = 2211942, upload-time = "2026-09-15T22:31:47.368Z" }, + { url = "https://files.pythonhosted.org/packages/24/a1/bd5e3e99bc9c8863b51ac5b9b03008a7f2da8c6b59695992f5c654e1265b/sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2", size = 1958015, upload-time = "2026-09-15T22:24:22.95Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, ] [[package]]