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 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-25 23:17:04 -05:00
parent 0002f75232
commit c2b3937e86
8 changed files with 221 additions and 6 deletions

View File

@@ -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},
)

View File

@@ -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')

View File

@@ -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)],

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

@@ -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:

View File

@@ -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)