From 52e9999a6123e4e1b4018b1ad4a071d300108729 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Thu, 14 May 2026 14:10:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(persistence):=20PR8=20T8.4=20=E2=80=94=20A?= =?UTF-8?q?piKeyRow=20ORM=20+=203=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds deerflow/persistence/api_key/{__init__,model}.py with ApiKeyRow: - id: UUID36 PK - service_account_id: FK service_accounts ON DELETE CASCADE - key_prefix: String(16), UNIQUE — global uniqueness preserved even after revoke so audit logs never reference an ambiguous prefix - key_hash: String(128) sha-256 hex (plaintext returned only once at create time) - name / scopes / rate_limit_rpm / expires_at / last_used_at / revoked_at — all nullable or default-providing - created_at: UTC - Index idx_api_keys_sa (service_account_id) — list keys for an SA - Index idx_api_keys_active (key_prefix) WHERE revoked_at IS NULL — partial index, dual-dialect via sqlite_where + postgresql_where, shrinks the hot-path lookup index by excluding revoked keys T8.4 tests: - test_unique_key_prefix_enforced: column-level UNIQUE blocks two rows from sharing key_prefix (active or revoked alike) - test_active_index_declares_both_dialect_where_clauses: schema introspection confirms idx_api_keys_active has both `dialect_options.sqlite.where` and `dialect_options.postgresql.where` set to `revoked_at IS NULL` — guards against accidental loss of the dual-driver hint when the Index is edited later - test_cascade_on_service_account_delete: deleting the parent SA removes all api_keys rows Registered in deerflow/persistence/models/__init__.py. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deerflow/persistence/api_key/__init__.py | 18 ++ .../deerflow/persistence/api_key/model.py | 94 +++++++++++ .../deerflow/persistence/models/__init__.py | 3 + backend/tests/test_api_key_schema.py | 156 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 backend/packages/harness/deerflow/persistence/api_key/__init__.py create mode 100644 backend/packages/harness/deerflow/persistence/api_key/model.py create mode 100644 backend/tests/test_api_key_schema.py diff --git a/backend/packages/harness/deerflow/persistence/api_key/__init__.py b/backend/packages/harness/deerflow/persistence/api_key/__init__.py new file mode 100644 index 00000000..252b6c7b --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/api_key/__init__.py @@ -0,0 +1,18 @@ +"""API key persistence — ORM model only (Stage 0 PR8). + +An API key is the credential a service_account uses to call the +headless API. Each key has a public ``key_prefix`` (printed in audit +logs and used for quick lookup) and a ``key_hash`` (sha-256 of the +plaintext token, never reversed). Plaintext tokens are only ever +returned to the caller at create time. + +PR8 introduces only the schema + ORM row class. Token generation, +hashing, scope parsing, rate limiting, and the API-key auth +middleware live in Stage 1 alongside the headless API surface. +""" + +from __future__ import annotations + +from deerflow.persistence.api_key.model import ApiKeyRow + +__all__ = ["ApiKeyRow"] diff --git a/backend/packages/harness/deerflow/persistence/api_key/model.py b/backend/packages/harness/deerflow/persistence/api_key/model.py new file mode 100644 index 00000000..ae7f1734 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/api_key/model.py @@ -0,0 +1,94 @@ +"""ORM model for API keys (credentials owned by a service account).""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base + + +class ApiKeyRow(Base): + __tablename__ = "api_keys" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + comment="API key 主键,UUID 字符串(36 字符)", + ) + service_account_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("service_accounts.id", ondelete="CASCADE"), + nullable=False, + comment="所属 service_account;service_account 删除时级联清掉所有 api_key", + ) + key_prefix: Mapped[str] = mapped_column( + String(16), + nullable=False, + unique=True, + comment="公开 prefix(如 'dfk_live_abc12345'),可在审计日志 / UI 中打印;全局唯一,撤销后亦不复用以避免审计混淆", + ) + key_hash: Mapped[str] = mapped_column( + String(128), + nullable=False, + comment="完整 token 的 sha-256 hex(64 字符;预留 128 以兼容未来更长哈希),plaintext token 仅在创建时返回给调用方", + ) + name: Mapped[str] = mapped_column( + String(64), + nullable=False, + comment="key 的人类可读标签(如 'ci pipeline' / 'frontend prod'),同 service_account 内不强制唯一", + ) + scopes: Mapped[str] = mapped_column( + String(1024), + nullable=False, + default="", + comment="scope 列表,逗号分隔字符串(如 'threads:read,threads:write');用 String 而非 PG text[] 以保 SQLite dev 兼容,Stage 2 切纯 PG 后可平滑迁", + ) + rate_limit_rpm: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + comment="每分钟请求数限制;NULL 表示走该 service_account 的默认限速(Stage 1 起生效)", + ) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="过期时间(UTC);NULL = 不过期", + ) + last_used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="最近一次成功鉴权时间(UTC);Stage 1 鉴权中间件每次更新", + ) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="撤销时间(UTC);NULL = 仍然有效。被撤销的 key 不删行(保留审计),但鉴权层据此拒绝", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + comment="创建时间(UTC)", + ) + + __table_args__ = ( + Index("idx_api_keys_sa", "service_account_id"), + # 部分索引:只索引活跃 key(revoked_at IS NULL),鉴权热路径走 prefix lookup, + # 撤销后的 key 不进活跃索引以减小热索引大小。SQLite + Postgres 均支持 + # WHERE 子句的部分索引;双驱动维护两套等价 where 表达式。 + Index( + "idx_api_keys_active", + "key_prefix", + sqlite_where=text("revoked_at IS NULL"), + postgresql_where=text("revoked_at IS NULL"), + ), + { + "comment": ( + "API key 表(headless API 凭证)。每行属于唯一 service_account;key_prefix 全局唯一可在日志中打印," + "key_hash 是完整 token 的 sha-256,plaintext token 只在创建时返给调用方。撤销保留行(revoked_at 非空)," + "活跃 key 走部分索引 idx_api_keys_active 加速鉴权热路径。Stage 0 仅落 schema;Stage 1 起接鉴权 + 限速。" + ) + }, + ) diff --git a/backend/packages/harness/deerflow/persistence/models/__init__.py b/backend/packages/harness/deerflow/persistence/models/__init__.py index 18e6be0c..2df09b14 100644 --- a/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -11,12 +11,14 @@ The actual ORM classes have moved to entity-specific subpackages: - ``deerflow.persistence.workspace`` (Stage 0 PR3) - ``deerflow.persistence.workspace_membership`` (Stage 0 PR3) - ``deerflow.persistence.service_account`` (Stage 0 PR8) +- ``deerflow.persistence.api_key`` (Stage 0 PR8) ``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because its storage implementation lives in ``deerflow.runtime.events.store.db`` and there is no matching entity directory. """ +from deerflow.persistence.api_key.model import ApiKeyRow from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.run.model import RunRow @@ -27,6 +29,7 @@ from deerflow.persistence.workspace.model import WorkspaceRow from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow __all__ = [ + "ApiKeyRow", "FeedbackRow", "RunEventRow", "RunRow", diff --git a/backend/tests/test_api_key_schema.py b/backend/tests/test_api_key_schema.py new file mode 100644 index 00000000..c4e06dc8 --- /dev/null +++ b/backend/tests/test_api_key_schema.py @@ -0,0 +1,156 @@ +"""Schema tests for ``ApiKeyRow`` (Stage 0 PR8). + +PR8 is schema-only — no repository class, no API. Tests exercise raw +ORM behaviour: column-level UNIQUE on key_prefix, the dual-dialect +partial index DDL (sqlite_where + postgresql_where), and CASCADE on +service_account delete. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from sqlalchemy import delete +from sqlalchemy.exc import IntegrityError + +from deerflow.persistence.api_key import ApiKeyRow +from deerflow.persistence.service_account import ServiceAccountRow +from deerflow.persistence.user.model import UserRow +from deerflow.persistence.workspace.model import WorkspaceRow + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def _setup(tmp_path): + from deerflow.persistence.engine import get_session_factory, init_engine + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + return get_session_factory() + + +async def _cleanup(): + from deerflow.persistence.engine import close_engine + + await close_engine() + + +async def _seed_parents(sf) -> None: + now = datetime.now(UTC) + async with sf() as session: + session.add(UserRow(id="u-alice", email="alice@example.com")) + await session.commit() + async with sf() as session: + session.add(WorkspaceRow(id="w-1", name="Alice WS", slug="alice", owner_id="u-alice")) + await session.commit() + async with sf() as session: + session.add( + ServiceAccountRow( + id="sa-1", + workspace_id="w-1", + name="bot", + role="member", + identity_mode="collapsed", + status="active", + created_by="u-alice", + created_at=now, + updated_at=now, + ) + ) + await session.commit() + + +def _make_key(*, key_id: str, prefix: str, revoked_at: datetime | None = None) -> ApiKeyRow: + now = datetime.now(UTC) + return ApiKeyRow( + id=key_id, + service_account_id="sa-1", + key_prefix=prefix, + key_hash="0" * 64, + name="default", + scopes="", + rate_limit_rpm=None, + expires_at=None, + last_used_at=None, + revoked_at=revoked_at, + created_at=now, + ) + + +# --------------------------------------------------------------------------- +# T8.4-1 — column-level UNIQUE on key_prefix +# --------------------------------------------------------------------------- + + +async def test_unique_key_prefix_enforced(tmp_path): + """Two api_key rows sharing the same key_prefix raise IntegrityError.""" + sf = await _setup(tmp_path) + try: + await _seed_parents(sf) + async with sf() as session: + session.add(_make_key(key_id="ak-1", prefix="dfk_live_abc12345")) + await session.commit() + + with pytest.raises(IntegrityError): + async with sf() as session: + session.add(_make_key(key_id="ak-2", prefix="dfk_live_abc12345")) + await session.commit() + finally: + await _cleanup() + + +# --------------------------------------------------------------------------- +# T8.4-2 — partial index DDL covers both SQLite and Postgres +# --------------------------------------------------------------------------- + + +def test_active_index_declares_both_dialect_where_clauses(): + """idx_api_keys_active must compile to a partial index on both drivers. + + The plan locks ``sqlite_where`` *and* ``postgresql_where`` so the same + Index emits a partial index regardless of backend (Stage 0 dev still + runs SQLite locally; production is Postgres). Both ``dialect_options`` + entries must be present. + """ + active_idx = next((idx for idx in ApiKeyRow.__table__.indexes if idx.name == "idx_api_keys_active"), None) + assert active_idx is not None, "idx_api_keys_active not declared" + sqlite_where = active_idx.dialect_options.get("sqlite", {}).get("where") + postgres_where = active_idx.dialect_options.get("postgresql", {}).get("where") + assert sqlite_where is not None, "sqlite_where missing on idx_api_keys_active" + assert postgres_where is not None, "postgresql_where missing on idx_api_keys_active" + assert "revoked_at IS NULL" in str(sqlite_where) + assert "revoked_at IS NULL" in str(postgres_where) + + +# --------------------------------------------------------------------------- +# T8.4-3 — CASCADE on service_account delete +# --------------------------------------------------------------------------- + + +async def test_cascade_on_service_account_delete(tmp_path): + """Deleting the parent service_account removes all child api_keys.""" + sf = await _setup(tmp_path) + try: + await _seed_parents(sf) + async with sf() as session: + session.add(_make_key(key_id="ak-cascade-1", prefix="dfk_live_cascade1")) + session.add(_make_key(key_id="ak-cascade-2", prefix="dfk_live_cascade2")) + await session.commit() + + async with sf() as session: + await session.execute(delete(ServiceAccountRow).where(ServiceAccountRow.id == "sa-1")) + await session.commit() + + async with sf() as session: + row1 = await session.get(ApiKeyRow, "ak-cascade-1") + row2 = await session.get(ApiKeyRow, "ak-cascade-2") + assert row1 is None + assert row2 is None + finally: + await _cleanup()