From c2b3937e86ca9ad012d694a385ecd6df515f6ea1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 25 Feb 2026 23:17:04 -0500 Subject: [PATCH] fix: add ResolutionFlow service account to own default tree steps in library Default/system trees had no author_id (NULL), causing a NOT NULL violation when syncing steps to step_library.created_by on publish. - Add is_service_account flag to users table (migration 4f4137ce) - Add service_account.py: idempotent ensure_service_account() creates noreply@resolutionflow.com with unusable password on startup - Cache service account ID on app.state at lifespan startup - Add get_service_account_id() FastAPI dep (returns None in tests) - sync_steps_from_tree: resolve author_id or service_account_id as created_by - create_tree: set author_id=service_account_id for is_default trees - Migration 1490781700bc: backfill author_id on 31 existing default trees Co-Authored-By: Claude Sonnet 4.6 --- ...0bc_backfill_default_tree_author_id_to_.py | 94 +++++++++++++++++++ ...7ce79e5_add_is_service_account_to_users.py | 34 +++++++ backend/app/api/deps.py | 8 ++ backend/app/api/endpoints/trees.py | 11 ++- backend/app/core/service_account.py | 60 ++++++++++++ backend/app/core/step_sync.py | 12 ++- backend/app/main.py | 7 ++ backend/app/models/user.py | 1 + 8 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/1490781700bc_backfill_default_tree_author_id_to_.py create mode 100644 backend/alembic/versions/4f4137ce79e5_add_is_service_account_to_users.py create mode 100644 backend/app/core/service_account.py diff --git a/backend/alembic/versions/1490781700bc_backfill_default_tree_author_id_to_.py b/backend/alembic/versions/1490781700bc_backfill_default_tree_author_id_to_.py new file mode 100644 index 00000000..8d42e44c --- /dev/null +++ b/backend/alembic/versions/1490781700bc_backfill_default_tree_author_id_to_.py @@ -0,0 +1,94 @@ +"""backfill_default_tree_author_id_to_service_account + +Revision ID: 1490781700bc +Revises: 4f4137ce79e5 +Create Date: 2026-02-25 21:26:00.000000 + +Backfill author_id on is_default trees to the ResolutionFlow service account +(noreply@resolutionflow.com). The service account is created here if it does +not yet exist (idempotent), so this migration is safe to run before or after +the app starts. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +import uuid + + +# revision identifiers, used by Alembic. +revision: str = '1490781700bc' +down_revision: Union[str, None] = '4f4137ce79e5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com" +SERVICE_ACCOUNT_NAME = "ResolutionFlow" + + +def upgrade() -> None: + conn = op.get_bind() + + # Ensure service account exists + row = conn.execute( + sa.text("SELECT id FROM users WHERE email = :email"), + {"email": SERVICE_ACCOUNT_EMAIL}, + ).fetchone() + + if row is None: + service_id = str(uuid.uuid4()) + conn.execute( + sa.text(""" + INSERT INTO users ( + id, email, name, password_hash, role, + is_super_admin, is_team_admin, is_active, + is_service_account, must_change_password, + account_role, created_at + ) VALUES ( + :id, :email, :name, :password_hash, 'engineer', + false, false, true, + true, false, + 'engineer', NOW() + ) + """), + { + "id": service_id, + "email": SERVICE_ACCOUNT_EMAIL, + "name": SERVICE_ACCOUNT_NAME, + "password_hash": "!service-account-no-login", + }, + ) + else: + service_id = str(row[0]) + + # Backfill is_default trees that have no author + result = conn.execute( + sa.text(""" + UPDATE trees + SET author_id = :service_id + WHERE author_id IS NULL AND is_default = true + """), + {"service_id": service_id}, + ) + print(f"[backfill] Set author_id to service account on {result.rowcount} default trees") + + +def downgrade() -> None: + # Restore NULL on trees that were authored by the service account and are default + conn = op.get_bind() + row = conn.execute( + sa.text("SELECT id FROM users WHERE email = :email"), + {"email": SERVICE_ACCOUNT_EMAIL}, + ).fetchone() + if row is None: + return + service_id = str(row[0]) + conn.execute( + sa.text(""" + UPDATE trees + SET author_id = NULL + WHERE author_id = :service_id AND is_default = true + """), + {"service_id": service_id}, + ) diff --git a/backend/alembic/versions/4f4137ce79e5_add_is_service_account_to_users.py b/backend/alembic/versions/4f4137ce79e5_add_is_service_account_to_users.py new file mode 100644 index 00000000..c2566619 --- /dev/null +++ b/backend/alembic/versions/4f4137ce79e5_add_is_service_account_to_users.py @@ -0,0 +1,34 @@ +"""add_is_service_account_to_users + +Revision ID: 4f4137ce79e5 +Revises: fb1481317ff6 +Create Date: 2026-02-25 20:28:46.075639 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4f4137ce79e5' +down_revision: Union[str, None] = 'fb1481317ff6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'users', + sa.Column( + 'is_service_account', + sa.Boolean(), + nullable=False, + server_default='false', + ) + ) + + +def downgrade() -> None: + op.drop_column('users', 'is_service_account') diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index a23f7d59..6db727cf 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -155,6 +155,14 @@ async def require_account_owner( ) +def get_service_account_id(request: Request) -> Optional[UUID]: + """Return the cached ResolutionFlow service account UUID from app.state. + + Returns None in test environments where lifespan startup did not run. + """ + return getattr(request.app.state, "service_account_id", None) + + async def get_plan_limits_for_user( current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index ce1ad5ae..e0f10a6f 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -21,7 +21,7 @@ from app.schemas.tree import ( PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest ) from app.models.user_pinned_tree import UserPinnedTree -from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin +from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin, get_service_account_id from app.core.permissions import can_edit_tree, can_access_tree from app.core.filters import build_tree_access_filter from app.core.subscriptions import check_tree_limit @@ -400,7 +400,8 @@ async def get_tree( async def create_tree( tree_data: TreeCreate, db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(require_engineer_or_admin)] + current_user: Annotated[User, Depends(require_engineer_or_admin)], + service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)], ): """Create a new tree (engineers and admins only). @@ -465,7 +466,7 @@ async def create_tree( tree_type=tree_data.tree_type, tree_structure=tree_data.tree_structure, intake_form=intake_form_data, - author_id=None if is_default else current_user.id, # Default trees have no author + author_id=service_account_id if is_default else current_user.id, account_id=None if is_default else current_user.account_id, is_public=True if is_default else tree_data.is_public, # Default trees are always public is_default=is_default, @@ -549,7 +550,8 @@ async def update_tree( tree_id: UUID, tree_data: TreeUpdate, db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(require_engineer_or_admin)] + current_user: Annotated[User, Depends(require_engineer_or_admin)], + service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)], ): """Update an existing tree (engineers and admins only). @@ -654,6 +656,7 @@ async def update_tree( author_id=tree.author_id, account_id=tree.account_id, is_public=_is_public, + service_account_id=service_account_id, ) # Handle tags replacement diff --git a/backend/app/core/service_account.py b/backend/app/core/service_account.py new file mode 100644 index 00000000..d16e91c8 --- /dev/null +++ b/backend/app/core/service_account.py @@ -0,0 +1,60 @@ +"""ResolutionFlow system service account. + +This module manages the platform-level service account used as the author +for system/default content (seeded trees, synced step library entries, etc.). + +The service account ID is resolved once at startup and cached on app.state +so that sync operations can use it without a DB query per request. +""" +from __future__ import annotations + +import uuid +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com" +SERVICE_ACCOUNT_NAME = "ResolutionFlow" + + +async def ensure_service_account(db: AsyncSession) -> uuid.UUID: + """Ensure the ResolutionFlow service account exists and return its ID. + + Idempotent — safe to call on every startup. Creates the account if it + does not exist. The account has no usable password and is_service_account=True + so it can never log in via normal auth flows. + """ + from app.models.user import User + + result = await db.execute( + select(User).where(User.email == SERVICE_ACCOUNT_EMAIL) + ) + user = result.scalar_one_or_none() + + if user is not None: + if not user.is_service_account: + user.is_service_account = True + await db.commit() + return user.id + + # Create the service account with a random, unusable password hash + new_user = User( + id=uuid.uuid4(), + email=SERVICE_ACCOUNT_EMAIL, + name=SERVICE_ACCOUNT_NAME, + password_hash="!service-account-no-login", # bcrypt can't produce this prefix + role="engineer", + is_super_admin=False, + is_team_admin=False, + is_active=True, + is_service_account=True, + must_change_password=False, + account_role="engineer", + ) + db.add(new_user) + await db.commit() + logger.info(f"[service_account] Created service account (id={new_user.id})") + return new_user.id diff --git a/backend/app/core/step_sync.py b/backend/app/core/step_sync.py index 825a4ad9..b31f02fa 100644 --- a/backend/app/core/step_sync.py +++ b/backend/app/core/step_sync.py @@ -109,14 +109,22 @@ async def sync_steps_from_tree( tree_id: UUID, tree_type: str, tree_structure: dict, - author_id: UUID, + author_id: Optional[UUID], account_id: Optional[UUID], is_public: bool, + service_account_id: Optional[UUID] = None, ) -> int: """Upsert step library entries from a published tree. Returns the number of steps synced. + + For default/system trees that have no author_id, pass service_account_id + so that created_by is set to the ResolutionFlow service account. """ + resolved_author_id = author_id or service_account_id + if not resolved_author_id: + return 0 + now = datetime.now(timezone.utc) extracted = list(extract_steps_for_sync(tree_structure, tree_type)) @@ -157,7 +165,7 @@ async def sync_steps_from_tree( "title": step_data["title"], "step_type": step_data["step_type"], "content": json.dumps(step_data["content"]), - "created_by": str(author_id) if author_id else None, + "created_by": str(resolved_author_id), "account_id": str(account_id) if account_id else None, "visibility": visibility, "source_tree_id": str(tree_id), diff --git a/backend/app/main.py b/backend/app/main.py index 78462f76..a5db1b9f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware from app.core.rate_limit import limiter from app.api.router import api_router from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations +from app.core.service_account import ensure_service_account # Initialize logging configuration setup_logging() @@ -103,6 +104,12 @@ async def lifespan(app: FastAPI): # Note: In production, use Alembic migrations instead of init_db # await init_db() + # Ensure service account exists and cache its ID for sync operations + async with async_session_maker() as db: + service_account_id = await ensure_service_account(db) + app.state.service_account_id = service_account_id + logger.info(f"[service_account] Service account ready (id={service_account_id})") + # Start maintenance schedule runner + AI conversation cleanup scheduler.start() async with async_session_maker() as db: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 275ed5ca..ba6ba2b1 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -39,6 +39,7 @@ class User(Base): is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + is_service_account: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") # Account-based multi-tenancy (new)