Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
@@ -3,27 +3,73 @@
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-07-19
|
||||
|
||||
SAFE MIGRATION: Old tables are renamed (not dropped), data is migrated
|
||||
via INSERT ... SELECT, and old tables are preserved for rollback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, TSVECTOR, JSON
|
||||
|
||||
revision: str = "0021_unified_contacts"
|
||||
down_revision: Union[str, None] = "0020"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
revision = "0021_unified_contacts"
|
||||
down_revision = "0020"
|
||||
logger = logging.getLogger("alembic.migration.0021")
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 1. Drop old company_contacts join table
|
||||
op.execute("DROP TABLE IF EXISTS company_contacts CASCADE")
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""Check whether *table_name* exists in the public schema."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.tables "
|
||||
"WHERE table_schema = 'public' AND table_name = :t"
|
||||
),
|
||||
{"t": table_name},
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
# 2. Drop old contacts table (will recreate with new schema)
|
||||
op.execute("DROP TABLE IF EXISTS contacts CASCADE")
|
||||
|
||||
# 3. Drop old companies table
|
||||
op.execute("DROP TABLE IF EXISTS companies CASCADE")
|
||||
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
"""Check whether *column_name* exists on *table_name*."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' "
|
||||
"AND table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table_name, "c": column_name},
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
# 4. Create contacts table (without default_person_id/admin_contactperson_id FKs first)
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Rename old tables instead of dropping ──────────────────────
|
||||
# Only rename if the table exists and the _old version doesn't.
|
||||
old_tables = ["company_contacts", "contacts", "companies"]
|
||||
renamed: list[str] = []
|
||||
|
||||
for tbl in old_tables:
|
||||
old_name = f"{tbl}_old"
|
||||
if _table_exists(conn, tbl) and not _table_exists(conn, old_name):
|
||||
op.execute(f'ALTER TABLE "{tbl}" RENAME TO "{old_name}"')
|
||||
renamed.append(old_name)
|
||||
logger.info("Renamed %s → %s", tbl, old_name)
|
||||
elif _table_exists(conn, old_name):
|
||||
logger.info("%s already exists — skipping rename of %s", old_name, tbl)
|
||||
else:
|
||||
logger.info("Table %s does not exist — nothing to rename", tbl)
|
||||
|
||||
# ── 2. Create new contacts table ──────────────────────────────────
|
||||
op.create_table(
|
||||
"contacts",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
@@ -123,7 +169,7 @@ def upgrade():
|
||||
op.create_index("ix_contacts_code", "contacts", ["code"])
|
||||
op.create_index("ix_contacts_search_vec", "contacts", ["search_tsv"], postgresql_using="gin")
|
||||
|
||||
# 5. Create contactpersons table
|
||||
# ── 3. Create contactpersons table ────────────────────────────────
|
||||
op.create_table(
|
||||
"contactpersons",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
@@ -155,11 +201,175 @@ def upgrade():
|
||||
op.create_index("ix_contactpersons_contact", "contactpersons", ["contact_id"])
|
||||
op.create_index("ix_contactpersons_email", "contactpersons", ["email"])
|
||||
|
||||
# 6. Add FK columns to contacts that reference contactpersons
|
||||
# ── 4. Add FK columns to contacts that reference contactpersons ───
|
||||
op.add_column("contacts", sa.Column("default_person_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True))
|
||||
op.add_column("contacts", sa.Column("admin_contactperson_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True))
|
||||
|
||||
# ── 5. Migrate data from old tables ────────────────────────────────
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("contacts")
|
||||
# 5a. companies_old → contacts (type='company')
|
||||
if _table_exists(conn, "companies_old"):
|
||||
# Build column list dynamically based on what exists in companies_old
|
||||
company_cols = {
|
||||
"id": "id",
|
||||
"tenant_id": "tenant_id",
|
||||
"name": "name",
|
||||
"phone": "phone_1",
|
||||
"email": "email_1",
|
||||
"website": "website",
|
||||
"description": "projectnote",
|
||||
"deleted_at": "deleted_at",
|
||||
"created_by": "created_by",
|
||||
"updated_by": "updated_by",
|
||||
"created_at": "created_at",
|
||||
"updated_at": "updated_at",
|
||||
}
|
||||
# account_number → code (check if it exists)
|
||||
if _column_exists(conn, "companies_old", "account_number"):
|
||||
company_cols["account_number"] = "code"
|
||||
# industry → tags (check if it exists)
|
||||
if _column_exists(conn, "companies_old", "industry"):
|
||||
company_cols["industry"] = "tags"
|
||||
|
||||
select_cols = []
|
||||
insert_cols = []
|
||||
for old_col, new_col in company_cols.items():
|
||||
select_cols.append(old_col)
|
||||
insert_cols.append(new_col)
|
||||
|
||||
# Build the INSERT ... SELECT statement
|
||||
select_list = ", ".join(f'"{c}"' for c in select_cols)
|
||||
# Add computed columns
|
||||
select_list += ", 'company' AS type, "
|
||||
# displayname = name
|
||||
if "name" in select_cols:
|
||||
select_list += '"name" AS displayname'
|
||||
else:
|
||||
select_list += "'' AS displayname"
|
||||
|
||||
insert_list = ", ".join(f'"{c}"' for c in insert_cols) + ', "type", "displayname"'
|
||||
|
||||
sql = f'INSERT INTO contacts ({insert_list}) SELECT {select_list} FROM companies_old'
|
||||
op.execute(sql)
|
||||
|
||||
row_count = conn.execute(sa.text("SELECT COUNT(*) FROM companies_old")).scalar()
|
||||
logger.info("Migrated %d rows from companies_old → contacts (type='company')", row_count or 0)
|
||||
|
||||
# 5b. contacts_old → contacts (type='person')
|
||||
if _table_exists(conn, "contacts_old"):
|
||||
# Map old contact columns to new contacts columns
|
||||
contact_cols = {
|
||||
"id": "id",
|
||||
"tenant_id": "tenant_id",
|
||||
"first_name": "firstname",
|
||||
"last_name": "surname",
|
||||
"email": "email_1",
|
||||
"phone": "phone_1",
|
||||
"deleted_at": "deleted_at",
|
||||
"created_by": "created_by",
|
||||
"updated_by": "updated_by",
|
||||
"created_at": "created_at",
|
||||
"updated_at": "updated_at",
|
||||
}
|
||||
# mobile → phone_2
|
||||
if _column_exists(conn, "contacts_old", "mobile"):
|
||||
contact_cols["mobile"] = "phone_2"
|
||||
# notes → projectnote
|
||||
if _column_exists(conn, "contacts_old", "notes"):
|
||||
contact_cols["notes"] = "projectnote"
|
||||
|
||||
select_cols = []
|
||||
insert_cols = []
|
||||
for old_col, new_col in contact_cols.items():
|
||||
select_cols.append(old_col)
|
||||
insert_cols.append(new_col)
|
||||
|
||||
select_list = ", ".join(f'"{c}"' for c in select_cols)
|
||||
# Add computed columns
|
||||
select_list += ", 'person' AS type, "
|
||||
# displayname = first_name || ' ' || last_name
|
||||
if _column_exists(conn, "contacts_old", "first_name") and _column_exists(conn, "contacts_old", "last_name"):
|
||||
select_list += "COALESCE(first_name, '') || ' ' || COALESCE(last_name, '') AS displayname"
|
||||
elif _column_exists(conn, "contacts_old", "first_name"):
|
||||
select_list += "first_name AS displayname"
|
||||
else:
|
||||
select_list += "'' AS displayname"
|
||||
|
||||
insert_list = ", ".join(f'"{c}"' for c in insert_cols) + ', "type", "displayname"'
|
||||
|
||||
sql = f'INSERT INTO contacts ({insert_list}) SELECT {select_list} FROM contacts_old'
|
||||
op.execute(sql)
|
||||
|
||||
row_count = conn.execute(sa.text("SELECT COUNT(*) FROM contacts_old")).scalar()
|
||||
logger.info("Migrated %d rows from contacts_old → contacts (type='person')", row_count or 0)
|
||||
|
||||
# 5c. company_contacts_old → contactpersons
|
||||
# Each row links a company to a person. In the new schema, contactpersons
|
||||
# are persons attached to a company contact. We map:
|
||||
# contact_id (FK to contacts) = company_id (the company, now a contact)
|
||||
# person details come from the old contacts table
|
||||
if _table_exists(conn, "company_contacts_old") and _table_exists(conn, "contacts_old"):
|
||||
sql = """
|
||||
INSERT INTO contactpersons (
|
||||
id, tenant_id, contact_id, displayname,
|
||||
firstname, lastname, function, phone, email,
|
||||
tags, created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
cc.tenant_id,
|
||||
cc.company_id,
|
||||
COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, ''),
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
cc.role_at_company,
|
||||
c.phone,
|
||||
c.email,
|
||||
CASE WHEN cc.is_primary THEN 'primary' ELSE NULL END,
|
||||
cc.created_at,
|
||||
cc.updated_at,
|
||||
cc.deleted_at
|
||||
FROM company_contacts_old cc
|
||||
JOIN contacts_old c ON cc.contact_id = c.id
|
||||
"""
|
||||
op.execute(sql)
|
||||
|
||||
row_count = conn.execute(sa.text("SELECT COUNT(*) FROM company_contacts_old")).scalar()
|
||||
logger.info("Migrated %d rows from company_contacts_old → contactpersons", row_count or 0)
|
||||
|
||||
# ── 6. Enable RLS on new tables ───────────────────────────────────
|
||||
for table_name in ["contacts", "contactpersons"]:
|
||||
op.execute(f'ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY')
|
||||
op.execute(f'DROP POLICY IF EXISTS tenant_isolation ON "{table_name}"')
|
||||
op.execute(
|
||||
f'CREATE POLICY tenant_isolation ON "{table_name}" '
|
||||
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop RLS policies on new tables
|
||||
for table_name in ["contactpersons", "contacts"]:
|
||||
op.execute(f'DROP POLICY IF EXISTS tenant_isolation ON "{table_name}"')
|
||||
op.execute(f'ALTER TABLE "{table_name}" DISABLE ROW LEVEL SECURITY')
|
||||
|
||||
# Drop FK columns from contacts
|
||||
op.drop_column("contacts", "admin_contactperson_id")
|
||||
op.drop_column("contacts", "default_person_id")
|
||||
|
||||
# Drop new tables
|
||||
op.drop_table("contactpersons")
|
||||
op.drop_table("contacts")
|
||||
|
||||
# Restore old tables by renaming _old suffix back
|
||||
for tbl in ["companies", "contacts", "company_contacts"]:
|
||||
old_name = f"{tbl}_old"
|
||||
if _table_exists(conn, old_name) and not _table_exists(conn, tbl):
|
||||
op.execute(f'ALTER TABLE "{old_name}" RENAME TO "{tbl}"')
|
||||
logger.info("Restored %s → %s", old_name, tbl)
|
||||
elif _table_exists(conn, old_name) and _table_exists(conn, tbl):
|
||||
# Both exist — drop the _old version (new table takes precedence)
|
||||
op.execute(f'DROP TABLE "{old_name}" CASCADE')
|
||||
logger.info("Dropped leftover %s (new %s already exists)", old_name, tbl)
|
||||
|
||||
Reference in New Issue
Block a user