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:
60
backend/app/core/service_account.py
Normal file
60
backend/app/core/service_account.py
Normal 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
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user