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')
|
||||
Reference in New Issue
Block a user