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