feat(persistence): PR8 T8.4 — ApiKeyRow ORM + 3 tests

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) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-05-14 14:10:32 +08:00
parent bb7289781e
commit 52e9999a61
4 changed files with 271 additions and 0 deletions
@@ -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"]
@@ -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_accountservice_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 hex64 字符;预留 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"),
# 部分索引:只索引活跃 keyrevoked_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_accountkey_prefix 全局唯一可在日志中打印,"
"key_hash 是完整 token 的 sha-256plaintext token 只在创建时返给调用方。撤销保留行(revoked_at 非空),"
"活跃 key 走部分索引 idx_api_keys_active 加速鉴权热路径。Stage 0 仅落 schemaStage 1 起接鉴权 + 限速。"
)
},
)
@@ -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",
+156
View File
@@ -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()