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:
@@ -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_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 起接鉴权 + 限速。"
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user