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

@@ -18,12 +18,10 @@ async def list_target_lists(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all target lists for the current user's team."""
if not current_user.team_id:
return []
"""List all target lists for the current user's account."""
result = await db.execute(
select(TargetList)
.where(TargetList.team_id == current_user.team_id)
.where(TargetList.account_id == current_user.account_id)
.order_by(TargetList.name)
)
return result.scalars().all()
@@ -36,11 +34,9 @@ async def create_target_list(
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Create a new target list for the current team."""
if not current_user.team_id:
raise HTTPException(status_code=400, detail="User must belong to a team")
"""Create a new target list for the current account."""
target_list = TargetList(
team_id=current_user.team_id,
account_id=current_user.account_id,
created_by=current_user.id,
name=data.name,
description=data.description,
@@ -61,7 +57,7 @@ async def get_target_list(
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
TargetList.account_id == current_user.account_id,
)
)
target_list = result.scalar_one_or_none()
@@ -81,7 +77,7 @@ async def update_target_list(
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
TargetList.account_id == current_user.account_id,
)
)
target_list = result.scalar_one_or_none()
@@ -91,7 +87,7 @@ async def update_target_list(
if "name" in update_fields and data.name is not None:
target_list.name = data.name
if "description" in update_fields:
target_list.description = data.description # allow setting to None
target_list.description = data.description
if "targets" in update_fields and data.targets is not None:
target_list.targets = [t.model_dump() for t in data.targets]
await db.commit()
@@ -109,7 +105,7 @@ async def delete_target_list(
result = await db.execute(
select(TargetList).where(
TargetList.id == list_id,
TargetList.team_id == current_user.team_id,
TargetList.account_id == current_user.account_id,
)
)
target_list = result.scalar_one_or_none()

View File

@@ -1048,6 +1048,7 @@ async def create_tree_share(
# Create share
tree_share = TreeShare(
tree_id=tree.id,
account_id=current_user.account_id,
share_token=share_token,
created_by=current_user.id,
allow_forking=share_data.allow_forking,