52e9999a61
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>
95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
"""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 起接鉴权 + 限速。"
|
||
)
|
||
},
|
||
)
|