From 6f806ff4a4aed5fe586766eb19174007761bc5e8 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Thu, 14 May 2026 14:12:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(persistence):=20PR8=20T8.5=20=E2=80=94=20E?= =?UTF-8?q?xternalUserRow=20ORM=20+=202=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds deerflow/persistence/external_user/{__init__,model}.py with ExternalUserRow: - id: UUID36 PK - workspace_id: FK workspaces ON DELETE CASCADE (redundant with SA's workspace_id but stored directly to speed workspace-scoped queries that span multiple SAs) - service_account_id: FK service_accounts ON DELETE CASCADE - external_id: String(128) — caller-supplied X-External-User-Id - display_name: String(128) nullable (admin UI only, not auth-relevant) - metadata_json: JSON nullable=False default {} — plan tier / region / custom tags - created_at / last_seen_at (UTC) - UniqueConstraint (service_account_id, external_id) name=uq_external_users_sa_external — the same external_id may be reused under a different SA, but is upsert-unique under a single SA T8.5 tests: - test_unique_service_account_id_plus_external_id: second row with same (SA, external_id) raises IntegrityError - test_cascade_on_service_account_delete: deleting parent SA removes all external_users rows under it Registered in deerflow/persistence/models/__init__.py — all three PR8 tables (service_accounts / api_keys / external_users) are now wired into Base.metadata.create_all(). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../persistence/external_user/__init__.py | 16 +++ .../persistence/external_user/model.py | 71 ++++++++++ .../deerflow/persistence/models/__init__.py | 3 + backend/tests/test_external_user_schema.py | 134 ++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 backend/packages/harness/deerflow/persistence/external_user/__init__.py create mode 100644 backend/packages/harness/deerflow/persistence/external_user/model.py create mode 100644 backend/tests/test_external_user_schema.py diff --git a/backend/packages/harness/deerflow/persistence/external_user/__init__.py b/backend/packages/harness/deerflow/persistence/external_user/__init__.py new file mode 100644 index 00000000..3a93f6b4 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/external_user/__init__.py @@ -0,0 +1,16 @@ +"""External user persistence — ORM model only (Stage 0 PR8). + +An external user represents the end-user identity that a +service_account passes through on each call (typically via an +``X-External-User-Id`` header). The row is upserted each time a +new ``external_id`` is seen under a given service_account. + +PR8 introduces only the schema + ORM row class. The upsert logic, +header parsing, and quota attribution all live in Stage 1. +""" + +from __future__ import annotations + +from deerflow.persistence.external_user.model import ExternalUserRow + +__all__ = ["ExternalUserRow"] diff --git a/backend/packages/harness/deerflow/persistence/external_user/model.py b/backend/packages/harness/deerflow/persistence/external_user/model.py new file mode 100644 index 00000000..66fdef16 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/external_user/model.py @@ -0,0 +1,71 @@ +"""ORM model for external users (end-user identities passed through a service account).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base + + +class ExternalUserRow(Base): + __tablename__ = "external_users" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + comment="external_user 主键,UUID 字符串(36 字符)", + ) + workspace_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + comment="所属 workspace(冗余存储——可经 service_account 间接得到,但直接存以加速 workspace-scope 查询);workspace 删除时级联", + ) + service_account_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("service_accounts.id", ondelete="CASCADE"), + nullable=False, + comment="passthrough 的 service_account;service_account 删除时级联", + ) + external_id: Mapped[str] = mapped_column( + String(128), + nullable=False, + comment="终端调用方传入的 X-External-User-Id(最多 128 字符;推荐 UUID / opaque token,不要塞 PII)", + ) + display_name: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + comment="可选显示名(如 'alice@customer.com');仅用于 admin UI 展示,不参与鉴权", + ) + metadata_json: Mapped[dict[str, Any]] = mapped_column( + JSON, + nullable=False, + default=dict, + comment="任意 JSON 附属信息(plan tier / region / 自定义 tag);Stage 1 由 upsert 调用方写入", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + comment="首次见到该 external_id 的时间(UTC)", + ) + last_seen_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="最近一次该 external_id 触发请求的时间(UTC);Stage 1 鉴权层每次更新", + ) + + __table_args__ = ( + UniqueConstraint("service_account_id", "external_id", name="uq_external_users_sa_external"), + { + "comment": ( + "终端用户身份表(passthrough 模式下的 end-user)。每行由 service_account 的鉴权中间件 upsert——同一 " + "(service_account_id, external_id) 组合只存一行。workspace_id 冗余存储以加速跨 SA 的 workspace-scope 聚合查询。" + "Stage 0 仅落 schema;Stage 1 起接 upsert / 配额聚合。" + ) + }, + ) diff --git a/backend/packages/harness/deerflow/persistence/models/__init__.py b/backend/packages/harness/deerflow/persistence/models/__init__.py index 2df09b14..33ee93a3 100644 --- a/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -12,6 +12,7 @@ The actual ORM classes have moved to entity-specific subpackages: - ``deerflow.persistence.workspace_membership`` (Stage 0 PR3) - ``deerflow.persistence.service_account`` (Stage 0 PR8) - ``deerflow.persistence.api_key`` (Stage 0 PR8) +- ``deerflow.persistence.external_user`` (Stage 0 PR8) ``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because its storage implementation lives in ``deerflow.runtime.events.store.db`` and @@ -19,6 +20,7 @@ there is no matching entity directory. """ from deerflow.persistence.api_key.model import ApiKeyRow +from deerflow.persistence.external_user.model import ExternalUserRow from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.run.model import RunRow @@ -30,6 +32,7 @@ from deerflow.persistence.workspace_membership.model import WorkspaceMembershipR __all__ = [ "ApiKeyRow", + "ExternalUserRow", "FeedbackRow", "RunEventRow", "RunRow", diff --git a/backend/tests/test_external_user_schema.py b/backend/tests/test_external_user_schema.py new file mode 100644 index 00000000..c41769ca --- /dev/null +++ b/backend/tests/test_external_user_schema.py @@ -0,0 +1,134 @@ +"""Schema tests for ``ExternalUserRow`` (Stage 0 PR8). + +PR8 is schema-only — no repository class, no API. Tests exercise raw +ORM behaviour: the composite UNIQUE constraint (service_account_id, +external_id) and CASCADE on service_account delete. + +An external user is the end-user identity passed through by a +service_account whose ``identity_mode`` is ``external_passthrough`` or +``both``: every call carries an ``X-External-User-Id`` header which is +upserted into this table for audit / quota attribution. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from sqlalchemy import delete +from sqlalchemy.exc import IntegrityError + +from deerflow.persistence.external_user import ExternalUserRow +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="passthrough-bot", + role="member", + identity_mode="external_passthrough", + status="active", + created_by="u-alice", + created_at=now, + updated_at=now, + ) + ) + await session.commit() + + +def _make_external_user(*, eu_id: str, external_id: str) -> ExternalUserRow: + now = datetime.now(UTC) + return ExternalUserRow( + id=eu_id, + workspace_id="w-1", + service_account_id="sa-1", + external_id=external_id, + display_name=None, + metadata_json={}, + created_at=now, + last_seen_at=None, + ) + + +# --------------------------------------------------------------------------- +# T8.5-1 — UNIQUE (service_account_id, external_id) +# --------------------------------------------------------------------------- + + +async def test_unique_service_account_id_plus_external_id(tmp_path): + """The same external_id may be inserted twice only under different SAs.""" + sf = await _setup(tmp_path) + try: + await _seed_parents(sf) + async with sf() as session: + session.add(_make_external_user(eu_id="eu-1", external_id="client-42")) + await session.commit() + + with pytest.raises(IntegrityError): + async with sf() as session: + session.add(_make_external_user(eu_id="eu-2", external_id="client-42")) + await session.commit() + finally: + await _cleanup() + + +# --------------------------------------------------------------------------- +# T8.5-2 — CASCADE on service_account delete +# --------------------------------------------------------------------------- + + +async def test_cascade_on_service_account_delete(tmp_path): + """Deleting the parent service_account removes all child external_users rows.""" + sf = await _setup(tmp_path) + try: + await _seed_parents(sf) + async with sf() as session: + session.add(_make_external_user(eu_id="eu-c1", external_id="endpoint-A")) + session.add(_make_external_user(eu_id="eu-c2", external_id="endpoint-B")) + 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(ExternalUserRow, "eu-c1") + row2 = await session.get(ExternalUserRow, "eu-c2") + assert row1 is None + assert row2 is None + finally: + await _cleanup()