feat(persistence): PR8 T8.1 — ServiceAccountRow ORM + insert smoke
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) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ The actual ORM classes have moved to entity-specific subpackages:
|
|||||||
- ``deerflow.persistence.user``
|
- ``deerflow.persistence.user``
|
||||||
- ``deerflow.persistence.workspace`` (Stage 0 PR3)
|
- ``deerflow.persistence.workspace`` (Stage 0 PR3)
|
||||||
- ``deerflow.persistence.workspace_membership`` (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
|
``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because
|
||||||
its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
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.feedback.model import FeedbackRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
from deerflow.persistence.run.model import RunRow
|
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.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.user.model import UserRow
|
from deerflow.persistence.user.model import UserRow
|
||||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||||
@@ -28,6 +30,7 @@ __all__ = [
|
|||||||
"FeedbackRow",
|
"FeedbackRow",
|
||||||
"RunEventRow",
|
"RunEventRow",
|
||||||
"RunRow",
|
"RunRow",
|
||||||
|
"ServiceAccountRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"UserRow",
|
"UserRow",
|
||||||
"WorkspaceMembershipRow",
|
"WorkspaceMembershipRow",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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 升级。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user