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:
@@ -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},
|
||||||
|
)
|
||||||
@@ -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')
|
||||||
@@ -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(
|
async def get_plan_limits_for_user(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.schemas.tree import (
|
|||||||
PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest
|
PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest
|
||||||
)
|
)
|
||||||
from app.models.user_pinned_tree import UserPinnedTree
|
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.permissions import can_edit_tree, can_access_tree
|
||||||
from app.core.filters import build_tree_access_filter
|
from app.core.filters import build_tree_access_filter
|
||||||
from app.core.subscriptions import check_tree_limit
|
from app.core.subscriptions import check_tree_limit
|
||||||
@@ -400,7 +400,8 @@ async def get_tree(
|
|||||||
async def create_tree(
|
async def create_tree(
|
||||||
tree_data: TreeCreate,
|
tree_data: TreeCreate,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
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).
|
"""Create a new tree (engineers and admins only).
|
||||||
|
|
||||||
@@ -465,7 +466,7 @@ async def create_tree(
|
|||||||
tree_type=tree_data.tree_type,
|
tree_type=tree_data.tree_type,
|
||||||
tree_structure=tree_data.tree_structure,
|
tree_structure=tree_data.tree_structure,
|
||||||
intake_form=intake_form_data,
|
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,
|
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_public=True if is_default else tree_data.is_public, # Default trees are always public
|
||||||
is_default=is_default,
|
is_default=is_default,
|
||||||
@@ -549,7 +550,8 @@ async def update_tree(
|
|||||||
tree_id: UUID,
|
tree_id: UUID,
|
||||||
tree_data: TreeUpdate,
|
tree_data: TreeUpdate,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
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).
|
"""Update an existing tree (engineers and admins only).
|
||||||
|
|
||||||
@@ -654,6 +656,7 @@ async def update_tree(
|
|||||||
author_id=tree.author_id,
|
author_id=tree.author_id,
|
||||||
account_id=tree.account_id,
|
account_id=tree.account_id,
|
||||||
is_public=_is_public,
|
is_public=_is_public,
|
||||||
|
service_account_id=service_account_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle tags replacement
|
# Handle tags replacement
|
||||||
|
|||||||
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_id: UUID,
|
||||||
tree_type: str,
|
tree_type: str,
|
||||||
tree_structure: dict,
|
tree_structure: dict,
|
||||||
author_id: UUID,
|
author_id: Optional[UUID],
|
||||||
account_id: Optional[UUID],
|
account_id: Optional[UUID],
|
||||||
is_public: bool,
|
is_public: bool,
|
||||||
|
service_account_id: Optional[UUID] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Upsert step library entries from a published tree.
|
"""Upsert step library entries from a published tree.
|
||||||
|
|
||||||
Returns the number of steps synced.
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
extracted = list(extract_steps_for_sync(tree_structure, tree_type))
|
extracted = list(extract_steps_for_sync(tree_structure, tree_type))
|
||||||
|
|
||||||
@@ -157,7 +165,7 @@ async def sync_steps_from_tree(
|
|||||||
"title": step_data["title"],
|
"title": step_data["title"],
|
||||||
"step_type": step_data["step_type"],
|
"step_type": step_data["step_type"],
|
||||||
"content": json.dumps(step_data["content"]),
|
"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,
|
"account_id": str(account_id) if account_id else None,
|
||||||
"visibility": visibility,
|
"visibility": visibility,
|
||||||
"source_tree_id": str(tree_id),
|
"source_tree_id": str(tree_id),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
|||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
|
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
|
# Initialize logging configuration
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -103,6 +104,12 @@ async def lifespan(app: FastAPI):
|
|||||||
# Note: In production, use Alembic migrations instead of init_db
|
# Note: In production, use Alembic migrations instead of init_db
|
||||||
# await 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
|
# Start maintenance schedule runner + AI conversation cleanup
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class User(Base):
|
|||||||
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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_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_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")
|
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
|
||||||
# Account-based multi-tenancy (new)
|
# Account-based multi-tenancy (new)
|
||||||
|
|||||||
Reference in New Issue
Block a user