feat: tenant isolation Phase 3 — audit_logs, tree_shares, remaining RLS

P3-A: Add account_id to audit_logs model + migration (backfill via user_id →
  users.account_id). log_audit() gains optional account_id param with fallback
  SELECT to avoid churn across 40 call sites.

P3-B: Add account_id to tree_shares model + migration (backfill via created_by
  → users.account_id). TreeShare constructor updated in trees.py.

P3-C: Enable RLS on 6 remaining tables: step_ratings, step_usage_log,
  target_lists, session_shares, audit_logs, tree_shares.

P3-D: Drop team_id from target_lists — endpoint, schema, and model now use
  account_id as the sole isolation key.

P3-E: Append Phase 3 RLS isolation tests for all 6 tables.

test_target_lists.py: fix cross-account test to use Account model (not Team)
and set account_id on new User.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-11 05:02:43 +00:00
parent 00fdd663bc
commit e05472615b
13 changed files with 485 additions and 55 deletions

View File

@@ -0,0 +1,59 @@
"""Enable RLS on Phase 3 tables.
Tables covered:
- step_ratings (account_id NOT NULL since migration 7167e9374b0c)
- step_usage_log (account_id NOT NULL since migration 7167e9374b0c)
- target_lists (account_id NOT NULL since migration 2c6aabd89bc6)
- session_shares (account_id NOT NULL since session_share model)
- audit_logs (account_id NOT NULL since migration 2a9056eddd90)
- tree_shares (account_id NOT NULL since migration a05e1a1bea7c)
All use a standard intra-tenant isolation policy.
Token-based access to session_shares and tree_shares goes through
endpoints that use get_admin_db (BYPASSRLS), so a strict tenant
policy here is correct.
Revision ID: 04f013768235
Revises: a05e1a1bea7c
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = '04f013768235'
down_revision: Union[str, None] = 'a05e1a1bea7c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_CURRENT_ACCOUNT = (
"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000')::uuid"
)
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
_PHASE3_TABLES = [
"step_ratings",
"step_usage_log",
"target_lists",
"session_shares",
"audit_logs",
"tree_shares",
]
def upgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_STANDARD_USING})
""")
def downgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -0,0 +1,32 @@
"""Drop team_id from target_lists.
account_id (NOT NULL) is now the tenant isolation key; team_id is redundant.
All reads/writes use account_id via RLS + application filter.
Revision ID: 172ad76d7d20
Revises: 04f013768235
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '172ad76d7d20'
down_revision: Union[str, None] = '04f013768235'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index('ix_target_lists_team_id', table_name='target_lists', if_exists=True)
op.drop_constraint('target_lists_team_id_fkey', 'target_lists', type_='foreignkey')
op.drop_column('target_lists', 'team_id')
def downgrade() -> None:
op.add_column('target_lists', sa.Column('team_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'target_lists_team_id_fkey', 'target_lists', 'teams',
['team_id'], ['id'], ondelete='CASCADE',
)
op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id'])

View File

@@ -0,0 +1,51 @@
"""Add account_id to audit_logs and backfill via user_id.
Revision ID: 2a9056eddd90
Revises: 70a5dd746e83
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2a9056eddd90'
down_revision: Union[str, None] = '70a5dd746e83'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('audit_logs', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_audit_logs_account_id', 'audit_logs', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: derive from the acting user's account
op.execute("""
UPDATE audit_logs al
SET account_id = u.account_id
FROM users u
WHERE al.user_id = u.id
AND u.account_id IS NOT NULL
AND al.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM audit_logs WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} audit_logs rows have NULL account_id after backfill. "
"All audit log entries must have an associated user with an account."
)
op.alter_column('audit_logs', 'account_id', nullable=False)
op.create_index('ix_audit_logs_account_id', 'audit_logs', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_audit_logs_account_id', table_name='audit_logs')
op.drop_constraint('fk_audit_logs_account_id', 'audit_logs', type_='foreignkey')
op.drop_column('audit_logs', 'account_id')

View File

@@ -0,0 +1,51 @@
"""Add account_id to tree_shares and backfill via created_by user.
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 creating user's account
op.execute("""
UPDATE tree_shares ts
SET account_id = u.account_id
FROM users u
WHERE ts.created_by = u.id
AND u.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')