From 9166ab205d578c52937acbabbe1580a3f0eaefe4 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sun, 28 Jun 2026 11:08:20 +0800 Subject: [PATCH] feat(persistence): ServiceAccountRepository (Stage 1 PR1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../persistence/service_account/__init__.py | 3 +- .../persistence/service_account/sql.py | 92 ++++++++++++++++++ backend/tests/test_service_account_repo.py | 97 +++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 backend/packages/harness/deerflow/persistence/service_account/sql.py create mode 100644 backend/tests/test_service_account_repo.py diff --git a/backend/packages/harness/deerflow/persistence/service_account/__init__.py b/backend/packages/harness/deerflow/persistence/service_account/__init__.py index d27c5372..3ff16eea 100644 --- a/backend/packages/harness/deerflow/persistence/service_account/__init__.py +++ b/backend/packages/harness/deerflow/persistence/service_account/__init__.py @@ -13,5 +13,6 @@ upgrade live in Stage 1 alongside the headless API surface. from __future__ import annotations from deerflow.persistence.service_account.model import ServiceAccountRow +from deerflow.persistence.service_account.sql import ServiceAccountRepository -__all__ = ["ServiceAccountRow"] +__all__ = ["ServiceAccountRepository", "ServiceAccountRow"] diff --git a/backend/packages/harness/deerflow/persistence/service_account/sql.py b/backend/packages/harness/deerflow/persistence/service_account/sql.py new file mode 100644 index 00000000..3363bf1f --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/service_account/sql.py @@ -0,0 +1,92 @@ +"""SQLAlchemy-backed service account repository (Stage 1 PR1). + +Mirrors :class:`WorkspaceRepository`: fresh session per method, +``_row_to_dict`` static helper. Workspace scoping is enforced by the +caller (route layer reads the workspace contextvar); the repository +takes ``workspace_id`` explicitly. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.service_account.model import ServiceAccountRow + +_VALID_STATUSES = frozenset({"active", "suspended", "deleted"}) + + +class ServiceAccountRepository: + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + @staticmethod + def _row_to_dict(row: ServiceAccountRow) -> dict[str, Any]: + return { + "id": row.id, + "workspace_id": row.workspace_id, + "name": row.name, + "role": row.role, + "identity_mode": row.identity_mode, + "status": row.status, + "created_by": row.created_by, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + async def create( + self, + *, + workspace_id: str, + name: str, + created_by: str, + role: str = "member", + identity_mode: str = "collapsed", + status: str = "active", + ) -> dict[str, Any]: + now = datetime.now(UTC) + row = ServiceAccountRow( + id=str(uuid.uuid4()), + workspace_id=workspace_id, + name=name, + role=role, + identity_mode=identity_mode, + status=status, + created_by=created_by, + created_at=now, + updated_at=now, + ) + async with self._sf() as session: + session.add(row) + await session.commit() + await session.refresh(row) + return self._row_to_dict(row) + + async def get(self, sa_id: str) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(ServiceAccountRow, sa_id) + return self._row_to_dict(row) if row else None + + async def get_active(self, sa_id: str) -> dict[str, Any] | None: + """Return the row only when ``status == 'active'`` (auth hot path).""" + async with self._sf() as session: + row = await session.get(ServiceAccountRow, sa_id) + if row is None or row.status != "active": + return None + return self._row_to_dict(row) + + async def list_by_workspace(self, workspace_id: str) -> list[dict[str, Any]]: + async with self._sf() as session: + result = await session.execute(select(ServiceAccountRow).where(ServiceAccountRow.workspace_id == workspace_id).order_by(ServiceAccountRow.created_at.desc())) + return [self._row_to_dict(r) for r in result.scalars()] + + async def update_status(self, sa_id: str, status: str) -> None: + if status not in _VALID_STATUSES: + raise ValueError(f"status {status!r} not in {_VALID_STATUSES!r}") + async with self._sf() as session: + await session.execute(update(ServiceAccountRow).where(ServiceAccountRow.id == sa_id).values(status=status, updated_at=datetime.now(UTC))) + await session.commit() diff --git a/backend/tests/test_service_account_repo.py b/backend/tests/test_service_account_repo.py new file mode 100644 index 00000000..5ec8ce59 --- /dev/null +++ b/backend/tests/test_service_account_repo.py @@ -0,0 +1,97 @@ +"""Tests for ServiceAccountRepository (Stage 1 PR1). + +SQLite ephemeral DB per test, mirroring test_workspace_repo / test_api_key_schema. +""" + +from __future__ import annotations + +import pytest + +from deerflow.persistence.service_account import ServiceAccountRepository +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 _make_repo(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 ServiceAccountRepository(get_session_factory()) + + +async def _cleanup(): + from deerflow.persistence.engine import close_engine + + await close_engine() + + +async def _seed_parents(repo, *, user_id="u-alice", workspace_id="w-1") -> None: + async with repo._sf() as session: + session.add(UserRow(id=user_id, email=f"{user_id}@example.com")) + await session.commit() + async with repo._sf() as session: + session.add(WorkspaceRow(id=workspace_id, name="WS", slug=workspace_id, owner_id=user_id)) + await session.commit() + + +async def test_create_then_get_roundtrip(tmp_path): + repo = await _make_repo(tmp_path) + try: + await _seed_parents(repo) + created = await repo.create(workspace_id="w-1", name="ci-bot", created_by="u-alice") + assert created["workspace_id"] == "w-1" + assert created["name"] == "ci-bot" + assert created["role"] == "member" + assert created["identity_mode"] == "collapsed" + assert created["status"] == "active" + assert len(created["id"]) == 36 + + fetched = await repo.get(created["id"]) + assert fetched is not None + assert fetched["id"] == created["id"] + finally: + await _cleanup() + + +async def test_get_returns_none_when_missing(tmp_path): + repo = await _make_repo(tmp_path) + try: + assert await repo.get("nope") is None + finally: + await _cleanup() + + +async def test_get_active_excludes_suspended(tmp_path): + repo = await _make_repo(tmp_path) + try: + await _seed_parents(repo) + sa = await repo.create(workspace_id="w-1", name="bot", created_by="u-alice") + assert await repo.get_active(sa["id"]) is not None + await repo.update_status(sa["id"], "suspended") + assert await repo.get_active(sa["id"]) is None + # get() still returns the row regardless of status + assert await repo.get(sa["id"]) is not None + finally: + await _cleanup() + + +async def test_list_by_workspace(tmp_path): + repo = await _make_repo(tmp_path) + try: + await _seed_parents(repo) + await _seed_parents(repo, user_id="u-bob", workspace_id="w-2") + await repo.create(workspace_id="w-1", name="a", created_by="u-alice") + await repo.create(workspace_id="w-1", name="b", created_by="u-alice") + await repo.create(workspace_id="w-2", name="c", created_by="u-bob") + rows = await repo.list_by_workspace("w-1") + assert {r["name"] for r in rows} == {"a", "b"} + finally: + await _cleanup()