Skip to content

Commit f63407a

Browse files
committed
Handle exact tenant column collisions
1 parent ce173f9 commit f63407a

File tree

5 files changed

+240
-11
lines changed

5 files changed

+240
-11
lines changed

alembic/versions/b5fc4168fe22_apply_model_a_rls_to_dynamic_workspace_.py

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from uuid import UUID
1313

1414
import sqlalchemy as sa
15+
from sqlalchemy.dialects.postgresql import JSONB
1516

1617
from alembic import op
1718
from tracecat.identifiers.workflow import WorkspaceUUID
@@ -23,6 +24,7 @@
2324
depends_on: str | Sequence[str] | None = None
2425

2526
INTERNAL_TENANT_COLUMN = "__tc_workspace_id"
27+
LEGACY_TENANT_COLUMN_PREFIX = "migrated_tc_workspace_id"
2628
DYNAMIC_WORKSPACE_RLS_POLICY = "rls_policy_dynamic_workspace"
2729
RLS_WORKSPACE_VAR = "app.current_workspace_id"
2830
RLS_BYPASS_VAR = "app.rls_bypass"
@@ -73,18 +75,147 @@ def _policy_expression() -> str:
7375
)
7476

7577

76-
def _ensure_tenant_column(
78+
def _next_legacy_tenant_column_name(existing_column_names: set[str]) -> str:
79+
"""Generate a non-conflicting rename target for legacy tenant collisions."""
80+
candidate = LEGACY_TENANT_COLUMN_PREFIX
81+
suffix = 1
82+
while candidate in existing_column_names:
83+
candidate = f"{LEGACY_TENANT_COLUMN_PREFIX}_{suffix}"
84+
suffix += 1
85+
return candidate
86+
87+
88+
def _rename_table_column_metadata(
89+
*,
90+
workspace_id: UUID,
91+
table_name: str,
92+
old_name: str,
93+
new_name: str,
94+
) -> None:
95+
"""Rename matching table-column metadata when a physical column moves."""
96+
bind = op.get_bind()
97+
inspector = sa.inspect(bind)
98+
if not (
99+
inspector.has_table("tables", schema="public")
100+
and inspector.has_table("table_column", schema="public")
101+
):
102+
return
103+
104+
bind.execute(
105+
sa.text(
106+
"""
107+
UPDATE table_column AS tc
108+
SET name = :new_name
109+
FROM tables AS t
110+
WHERE tc.table_id = t.id
111+
AND t.workspace_id = :workspace_id
112+
AND t.name = :table_name
113+
AND tc.name = :old_name
114+
"""
115+
),
116+
{
117+
"workspace_id": str(workspace_id),
118+
"table_name": table_name,
119+
"old_name": old_name,
120+
"new_name": new_name,
121+
},
122+
)
123+
124+
125+
def _rename_case_field_schema_key(
126+
*,
127+
workspace_id: UUID,
128+
old_name: str,
129+
new_name: str,
130+
) -> None:
131+
"""Rename a case-field schema key when the physical column moves."""
132+
bind = op.get_bind()
133+
inspector = sa.inspect(bind)
134+
if not inspector.has_table("case_field", schema="public"):
135+
return
136+
137+
case_field_table = sa.table(
138+
"case_field",
139+
sa.column("workspace_id", sa.UUID()),
140+
sa.column("schema", JSONB),
141+
)
142+
current_schema = bind.execute(
143+
sa.select(case_field_table.c.schema).where(
144+
case_field_table.c.workspace_id == workspace_id
145+
)
146+
).scalar_one_or_none()
147+
if not isinstance(current_schema, dict) or old_name not in current_schema:
148+
return
149+
150+
updated_schema = dict(current_schema)
151+
updated_schema[new_name] = updated_schema.pop(old_name)
152+
bind.execute(
153+
sa.update(case_field_table)
154+
.where(case_field_table.c.workspace_id == workspace_id)
155+
.values(schema=updated_schema)
156+
)
157+
158+
159+
def _rename_legacy_tenant_column(
77160
*,
78161
schema_name: str,
79162
table_name: str,
80163
workspace_id: UUID,
81164
preparer: sa.sql.compiler.IdentifierPreparer,
82165
) -> None:
83-
"""Add/backfill the internal tenant column on a physical dynamic table."""
166+
"""Rename any pre-existing user column that collides with the tenant column."""
84167
bind = op.get_bind()
85168
inspector = sa.inspect(bind)
169+
columns = inspector.get_columns(table_name, schema=schema_name)
170+
column_names = {column["name"] for column in columns}
171+
if INTERNAL_TENANT_COLUMN not in column_names:
172+
return
173+
174+
qualified_table = _qualified_table(preparer, schema_name, table_name)
175+
legacy_column_name = _next_legacy_tenant_column_name(column_names)
176+
op.execute(
177+
sa.text(
178+
f"""
179+
ALTER TABLE {qualified_table}
180+
RENAME COLUMN "{INTERNAL_TENANT_COLUMN}" TO "{legacy_column_name}"
181+
"""
182+
)
183+
)
184+
185+
if schema_name.startswith("tables_"):
186+
_rename_table_column_metadata(
187+
workspace_id=workspace_id,
188+
table_name=table_name,
189+
old_name=INTERNAL_TENANT_COLUMN,
190+
new_name=legacy_column_name,
191+
)
192+
elif schema_name.startswith("custom_fields_") and table_name == "case_fields":
193+
_rename_case_field_schema_key(
194+
workspace_id=workspace_id,
195+
old_name=INTERNAL_TENANT_COLUMN,
196+
new_name=legacy_column_name,
197+
)
198+
199+
200+
def _ensure_tenant_column(
201+
*,
202+
schema_name: str,
203+
table_name: str,
204+
workspace_id: UUID,
205+
preparer: sa.sql.compiler.IdentifierPreparer,
206+
) -> None:
207+
"""Add/backfill the internal tenant column on a physical dynamic table."""
86208
qualified_table = _qualified_table(preparer, schema_name, table_name)
87209

210+
_rename_legacy_tenant_column(
211+
schema_name=schema_name,
212+
table_name=table_name,
213+
workspace_id=workspace_id,
214+
preparer=preparer,
215+
)
216+
217+
bind = op.get_bind()
218+
inspector = sa.inspect(bind)
88219
has_tenant_column = any(
89220
column["name"] == INTERNAL_TENANT_COLUMN
90221
for column in inspector.get_columns(table_name, schema=schema_name)

tests/migration/test_dynamic_workspace_rls_migration.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@
2020
from tracecat.identifiers.workflow import WorkspaceUUID
2121

2222
MIGRATION_REVISION = "b5fc4168fe22"
23-
PREVIOUS_REVISION = "c76f9b01fad7"
23+
PREVIOUS_REVISION = "9a6d0e0ec5b1"
2424
TABLES_PREFIX = "tables_"
2525
CUSTOM_FIELDS_PREFIX = "custom_fields_"
2626
TABLES_TABLE = "alerts"
2727
CUSTOM_FIELDS_TABLE = "case_fields"
2828
INTERNAL_TENANT_COLUMN = "__tc_workspace_id"
29+
LEGACY_TENANT_COLUMN = "migrated_tc_workspace_id"
2930
DYNAMIC_WORKSPACE_RLS_POLICY = "rls_policy_dynamic_workspace"
3031

3132

@@ -263,6 +264,80 @@ def test_db():
263264

264265

265266
class TestDynamicWorkspaceRlsMigration:
267+
def test_upgrade_renames_legacy_exact_tenant_column_collision(
268+
self, test_db
269+
) -> None:
270+
engine = create_engine(test_db["db_url"])
271+
try:
272+
with engine.begin() as conn:
273+
workspace_id = test_db["workspace_ids"][0]
274+
for prefix, table_name in (
275+
(TABLES_PREFIX, TABLES_TABLE),
276+
(CUSTOM_FIELDS_PREFIX, CUSTOM_FIELDS_TABLE),
277+
):
278+
schema_name = _workspace_schema(prefix, workspace_id)
279+
conn.execute(
280+
text(
281+
f'''
282+
ALTER TABLE "{schema_name}"."{table_name}"
283+
ADD COLUMN "{INTERNAL_TENANT_COLUMN}" TEXT
284+
'''
285+
)
286+
)
287+
conn.execute(
288+
text(
289+
f'''
290+
UPDATE "{schema_name}"."{table_name}"
291+
SET "{INTERNAL_TENANT_COLUMN}" = :legacy_value
292+
'''
293+
),
294+
{"legacy_value": f"legacy-{table_name}"},
295+
)
296+
finally:
297+
engine.dispose()
298+
299+
_run_alembic_upgrade(test_db["db_url"])
300+
301+
engine = create_engine(test_db["db_url"])
302+
try:
303+
with engine.begin() as conn:
304+
workspace_id = test_db["workspace_ids"][0]
305+
for prefix, table_name in (
306+
(TABLES_PREFIX, TABLES_TABLE),
307+
(CUSTOM_FIELDS_PREFIX, CUSTOM_FIELDS_TABLE),
308+
):
309+
schema_name = _workspace_schema(prefix, workspace_id)
310+
columns = conn.execute(
311+
text(
312+
"""
313+
SELECT column_name
314+
FROM information_schema.columns
315+
WHERE table_schema = :schema_name
316+
AND table_name = :table_name
317+
"""
318+
),
319+
{
320+
"schema_name": schema_name,
321+
"table_name": table_name,
322+
},
323+
).fetchall()
324+
column_names = {row[0] for row in columns}
325+
assert INTERNAL_TENANT_COLUMN in column_names
326+
assert LEGACY_TENANT_COLUMN in column_names
327+
328+
tenant_values = conn.execute(
329+
text(
330+
f'''
331+
SELECT "{INTERNAL_TENANT_COLUMN}", "{LEGACY_TENANT_COLUMN}"
332+
FROM "{schema_name}"."{table_name}"
333+
'''
334+
)
335+
).one()
336+
assert tenant_values[0] == workspace_id
337+
assert tenant_values[1] == f"legacy-{table_name}"
338+
finally:
339+
engine.dispose()
340+
266341
def test_upgrade_backfills_tenant_column_and_sets_default(self, test_db) -> None:
267342
_run_alembic_upgrade(test_db["db_url"])
268343

tests/unit/test_case_fields_service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ async def test_create_field_rejects_internal_name(
180180
CaseFieldCreate(name="__TC_workspace_id", type=SqlType.TEXT)
181181
)
182182

183+
async def test_create_field_allows_legacy_tc_prefix_name(
184+
self, case_fields_service: CaseFieldsService
185+
) -> None:
186+
"""Only the exact internal tenant column name should be reserved."""
187+
with patch.object(
188+
case_fields_service.editor, "create_column"
189+
) as mock_create_column:
190+
field_params = CaseFieldCreate(name="__tc_shadow", type=SqlType.TEXT)
191+
await case_fields_service.create_field(field_params)
192+
mock_create_column.assert_called_once_with(field_params)
193+
183194
async def test_update_field(self, case_fields_service: CaseFieldsService) -> None:
184195
"""Test updating a case field."""
185196
# Create field update parameters

tests/unit/test_tables_service.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
TableEditorService,
3131
TablesService,
3232
is_internal_column_name,
33+
visible_column_names,
3334
)
3435

3536
pytestmark = pytest.mark.usefixtures("db")
@@ -151,9 +152,10 @@ async def _list_rows(
151152
@pytest.mark.anyio
152153
class TestTablesService:
153154
async def test_internal_column_name_check_is_case_insensitive(self) -> None:
154-
"""Internal column detection should normalize case before prefix checks."""
155+
"""Internal column detection should normalize case for exact matches."""
155156
assert is_internal_column_name("__tc_workspace_id") is True
156157
assert is_internal_column_name("__TC_workspace_id") is True
158+
assert is_internal_column_name("__tc_shadow") is False
157159
assert is_internal_column_name("user_field") is False
158160

159161
async def test_create_and_get_table(self, tables_service: TablesService) -> None:
@@ -942,6 +944,15 @@ async def test_table_editor_visible_columns_refresh_after_create_column(
942944
retrieved = await editor.get_row(inserted["id"])
943945
assert retrieved["city"] == "NYC"
944946

947+
async def test_visible_column_names_preserves_legacy_underscore_names(self) -> None:
948+
"""Visibility filtering should not reject legacy underscore-prefixed names."""
949+
assert visible_column_names(["_legacy_field", "__tc_workspace_id"]) == [
950+
"id",
951+
"created_at",
952+
"updated_at",
953+
"_legacy_field",
954+
]
955+
945956
async def test_table_editor_insert_row_rejects_internal_column(
946957
self, tables_service: TablesService, table: Table
947958
) -> None:

tracecat/tables/service.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,27 +72,28 @@
7272
InFailedSQLTransactionError,
7373
)
7474

75-
INTERNAL_COLUMN_PREFIX = "__tc_"
7675
DYNAMIC_WORKSPACE_TENANT_COLUMN = "__tc_workspace_id"
7776
DYNAMIC_WORKSPACE_RLS_POLICY = "rls_policy_dynamic_workspace"
7877
RLS_WORKSPACE_VAR = "app.current_workspace_id"
7978
RLS_BYPASS_VAR = "app.rls_bypass"
8079
RLS_BYPASS_ON = "on"
80+
INTERNAL_COLUMN_NAMES: frozenset[str] = frozenset({DYNAMIC_WORKSPACE_TENANT_COLUMN})
8181
SYSTEM_VISIBLE_COLUMN_NAMES: tuple[str, ...] = ("id", "created_at", "updated_at")
8282

8383

8484
def visible_column_names(column_names: Sequence[str]) -> list[str]:
8585
"""Build the API-facing column order for dynamic table row payloads."""
8686
visible_names: list[str] = list(SYSTEM_VISIBLE_COLUMN_NAMES)
87-
seen: set[str] = set(SYSTEM_VISIBLE_COLUMN_NAMES)
87+
seen_lower: set[str] = {
88+
column_name.lower() for column_name in SYSTEM_VISIBLE_COLUMN_NAMES
89+
}
8890
for column_name in column_names:
8991
if is_internal_column_name(column_name):
9092
continue
91-
sanitized_name = sanitize_identifier(column_name)
92-
if sanitized_name in seen:
93+
if (normalized_name := column_name.lower()) in seen_lower:
9394
continue
94-
visible_names.append(sanitized_name)
95-
seen.add(sanitized_name)
95+
visible_names.append(column_name)
96+
seen_lower.add(normalized_name)
9697
return visible_names
9798

9899

@@ -2239,4 +2240,4 @@ def sanitize_identifier(identifier: str) -> str:
22392240

22402241
def is_internal_column_name(column_name: str) -> bool:
22412242
"""Check whether a column is internal/system-managed for dynamic schemas."""
2242-
return column_name.lower().startswith(INTERNAL_COLUMN_PREFIX)
2243+
return column_name.lower() in INTERNAL_COLUMN_NAMES

0 commit comments

Comments
 (0)