- trees.py: change account_id=current_user.account_id → account_id=tree.account_id so super-admin cross-account shares land in the tree's tenant where RLS will see them. - migration a05e1a1bea7c: fix backfill to join tree_shares → trees instead of tree_shares → users(created_by). Same logic: historical shares belong to the tree's tenant. - test_tree_sharing.py: add test_share_account_id_matches_tree_not_actor to assert share.account_id == tree.account_id after POST /share; also add missing account_id to all direct TreeShare(...) constructors in existing tests. - test_phase1_migrations.py: remove team_id= from TargetList constructor (column dropped in Phase 3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
58 lines
2.0 KiB
Python
58 lines
2.0 KiB
Python
"""Add account_id to tree_shares and backfill via tree owner's account.
|
|
|
|
The share belongs to the tree's tenant, not the actor who created it.
|
|
A super admin in account A can share a tree owned by account B; that share
|
|
must land in account B so account B's RLS filter sees it.
|
|
|
|
Revision ID: a05e1a1bea7c
|
|
Revises: 2a9056eddd90
|
|
Create Date: 2026-04-11 00:00:00.000000
|
|
"""
|
|
from typing import Sequence, Union
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = 'a05e1a1bea7c'
|
|
down_revision: Union[str, None] = '2a9056eddd90'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.add_column('tree_shares', sa.Column('account_id', sa.UUID(), nullable=True))
|
|
op.create_foreign_key(
|
|
'fk_tree_shares_account_id', 'tree_shares', 'accounts',
|
|
['account_id'], ['id'], ondelete='CASCADE',
|
|
)
|
|
|
|
# Backfill: derive from the tree's account, not the creator's account.
|
|
# A share lives in the same tenant as its tree so that the tree owner's
|
|
# RLS context covers their own shares regardless of who created them.
|
|
op.execute("""
|
|
UPDATE tree_shares ts
|
|
SET account_id = t.account_id
|
|
FROM trees t
|
|
WHERE ts.tree_id = t.id
|
|
AND t.account_id IS NOT NULL
|
|
AND ts.account_id IS NULL
|
|
""")
|
|
|
|
result = op.get_bind().execute(
|
|
sa.text("SELECT COUNT(*) FROM tree_shares WHERE account_id IS NULL")
|
|
)
|
|
count = result.scalar()
|
|
if count > 0:
|
|
raise RuntimeError(
|
|
f"ROLLBACK: {count} tree_shares rows have NULL account_id after backfill. "
|
|
"All share entries must have a creating user with an account."
|
|
)
|
|
|
|
op.alter_column('tree_shares', 'account_id', nullable=False)
|
|
op.create_index('ix_tree_shares_account_id', 'tree_shares', ['account_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index('ix_tree_shares_account_id', table_name='tree_shares')
|
|
op.drop_constraint('fk_tree_shares_account_id', 'tree_shares', type_='foreignkey')
|
|
op.drop_column('tree_shares', 'account_id')
|