From 1fb07e48e6f87687de02048e5550b31addd03f50 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Thu, 14 May 2026 14:06:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(persistence):=20PR8=20T8.1=20=E2=80=94=20S?= =?UTF-8?q?erviceAccountRow=20ORM=20+=20insert=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds deerflow/persistence/service_account/{__init__,model}.py with ServiceAccountRow: - id: UUID36 PK - workspace_id: FK workspaces ON DELETE CASCADE - name: String(64) - role: String(16) default "member" (Stage 0 lone value; Stage 2 RBAC) - identity_mode: String(16) default "collapsed" — three states "collapsed" / "external_passthrough" / "both"; Stage 1 API key auth layer branches on this to decide whether each call writes an external_users row - status: String(16) default "active" - created_by: FK users ON DELETE RESTRICT (must hand off / delete SAs before removing their creator) - created_at / updated_at (UTC, onupdate) - Index idx_service_accounts_workspace (workspace_id, status) Registers in deerflow/persistence/models/__init__.py so the engine's side-effect import path picks it up for Base.metadata.create_all(). T8.1 test: insert_smoke writes a row through a session, reads it back, asserts each column round-trips. SQLite ephemeral DB per test via tmp_path, no Postgres required at this layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deerflow/persistence/models/__init__.py | 3 + .../persistence/service_account/__init__.py | 17 ++++ .../persistence/service_account/model.py | 83 ++++++++++++++++ backend/tests/test_service_account_schema.py | 94 +++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 backend/packages/harness/deerflow/persistence/service_account/__init__.py create mode 100644 backend/packages/harness/deerflow/persistence/service_account/model.py create mode 100644 backend/tests/test_service_account_schema.py diff --git a/backend/packages/harness/deerflow/persistence/models/__init__.py b/backend/packages/harness/deerflow/persistence/models/__init__.py index 710ba2fd..18e6be0c 100644 --- a/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -10,6 +10,7 @@ The actual ORM classes have moved to entity-specific subpackages: - ``deerflow.persistence.user`` - ``deerflow.persistence.workspace`` (Stage 0 PR3) - ``deerflow.persistence.workspace_membership`` (Stage 0 PR3) +- ``deerflow.persistence.service_account`` (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.feedback.model import FeedbackRow from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.run.model import RunRow +from deerflow.persistence.service_account.model import ServiceAccountRow from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.user.model import UserRow from deerflow.persistence.workspace.model import WorkspaceRow @@ -28,6 +30,7 @@ __all__ = [ "FeedbackRow", "RunEventRow", "RunRow", + "ServiceAccountRow", "ThreadMetaRow", "UserRow", "WorkspaceMembershipRow", diff --git a/backend/packages/harness/deerflow/persistence/service_account/__init__.py b/backend/packages/harness/deerflow/persistence/service_account/__init__.py new file mode 100644 index 00000000..d27c5372 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/service_account/__init__.py @@ -0,0 +1,17 @@ +"""Service account persistence — ORM model only (Stage 0 PR8). + +A service account is a non-human principal that lives inside a workspace +and authenticates via API keys rather than email + password. Each +service account belongs to exactly one workspace and is created by a +human user (``created_by``). + +PR8 introduces only the schema + ORM row class. Repository, API-key +authentication middleware, and the ``@require_permission`` scope +upgrade live in Stage 1 alongside the headless API surface. +""" + +from __future__ import annotations + +from deerflow.persistence.service_account.model import ServiceAccountRow + +__all__ = ["ServiceAccountRow"] diff --git a/backend/packages/harness/deerflow/persistence/service_account/model.py b/backend/packages/harness/deerflow/persistence/service_account/model.py new file mode 100644 index 00000000..27d5f76f --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/service_account/model.py @@ -0,0 +1,83 @@ +"""ORM model for service accounts (non-human principals inside a workspace).""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, Index, String +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base + + +class ServiceAccountRow(Base): + __tablename__ = "service_accounts" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + comment="服务账号主键,UUID 字符串(36 字符),与 users.id 类型对齐", + ) + workspace_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + comment="所属 workspace;workspace 删除时级联清掉所有 service_account(连同其 api_keys / external_users)", + ) + name: Mapped[str] = mapped_column( + String(64), + nullable=False, + comment="服务账号显示名(同 workspace 内不强制唯一;Stage 1 可由 admin UI 重复使用同名 + 不同 key)", + ) + role: Mapped[str] = mapped_column( + String(16), + nullable=False, + default="member", + comment='服务账号在 workspace 内的角色字符串:Stage 0 仅支持 "member";Stage 2 RBAC 打开 "admin"/"viewer"。用 String(16) 而非 enum 以便未来扩枚举值不动 schema', + ) + identity_mode: Mapped[str] = mapped_column( + String(16), + nullable=False, + default="collapsed", + comment=( + '身份模式三态:"collapsed"(所有调用 collapse 到该 service_account;不记录 external_user)/' + ' "external_passthrough"(每次调用必带 X-External-User-Id,写入 external_users 表)/' + ' "both"(带就写、不带就 collapse)。Stage 1 API key 鉴权层据此分流' + ), + ) + status: Mapped[str] = mapped_column( + String(16), + nullable=False, + default="active", + comment='状态:"active"(正常)/ "suspended"(admin 暂停)/ "deleted"(软删;保留审计)', + ) + created_by: Mapped[str] = mapped_column( + String(36), + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + comment="创建者 user_id;删除该 user 时 RESTRICT 阻拦(必须先转移或删除该 user 名下所有 service_account)", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + comment="创建时间(UTC)", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + comment="最近更新时间(UTC,写入时自动更新)", + ) + + __table_args__ = ( + Index("idx_service_accounts_workspace", "workspace_id", "status"), + { + "comment": ( + "服务账号表(headless API 的非人身份)。每个 service_account 属于唯一 workspace;" + "通过 api_keys 表的 API key 鉴权调用 Gateway;identity_mode 控制是否在 external_users 表" + "记录终端用户身份。Stage 0 仅落 schema;Stage 1 起接 API key 鉴权 + 路由 scope 升级。" + ) + }, + ) diff --git a/backend/tests/test_service_account_schema.py b/backend/tests/test_service_account_schema.py new file mode 100644 index 00000000..777463b1 --- /dev/null +++ b/backend/tests/test_service_account_schema.py @@ -0,0 +1,94 @@ +"""Schema tests for ``ServiceAccountRow`` (Stage 0 PR8). + +Pattern mirrors :mod:`test_workspace_repo`: ephemeral SQLite per test via +``tmp_path``, no Postgres required at this layer. + +PR8 is schema-only — no repository class, no API. Tests exercise raw +ORM behaviour: insert smoke, CASCADE on workspace delete, and RESTRICT +on the ``created_by`` user FK. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +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_user(sf, user_id: str = "u-alice", email: str = "alice@example.com") -> None: + async with sf() as session: + session.add(UserRow(id=user_id, email=email)) + await session.commit() + + +async def _seed_workspace(sf, workspace_id: str = "w-1", owner_id: str = "u-alice", slug: str = "alice") -> None: + async with sf() as session: + session.add(WorkspaceRow(id=workspace_id, name="Alice's WS", slug=slug, owner_id=owner_id)) + await session.commit() + + +# --------------------------------------------------------------------------- +# T8.1 — insert smoke +# --------------------------------------------------------------------------- + + +async def test_insert_smoke(tmp_path): + """A minimal service_account row can be inserted and read back.""" + sf = await _setup(tmp_path) + try: + await _seed_user(sf) + await _seed_workspace(sf) + + now = datetime.now(UTC) + async with sf() as session: + session.add( + ServiceAccountRow( + id="sa-1", + workspace_id="w-1", + name="ci-bot", + role="member", + identity_mode="collapsed", + status="active", + created_by="u-alice", + created_at=now, + updated_at=now, + ) + ) + await session.commit() + + async with sf() as session: + row = await session.get(ServiceAccountRow, "sa-1") + assert row is not None + assert row.workspace_id == "w-1" + assert row.name == "ci-bot" + assert row.role == "member" + assert row.identity_mode == "collapsed" + assert row.status == "active" + assert row.created_by == "u-alice" + finally: + await _cleanup()