diff --git a/backend/alembic/versions/20260205_create_profiles_table.py b/backend/alembic/versions/20260205_create_profiles_table.py deleted file mode 100644 index 7a34675..0000000 --- a/backend/alembic/versions/20260205_create_profiles_table.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - - -revision = "20260205_create_profiles_table" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "profiles", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("username", sa.String(length=30), nullable=False), - sa.Column("display_name", sa.String(length=50), nullable=True), - sa.Column("avatar_url", sa.Text(), nullable=True), - sa.Column("bio", sa.String(length=200), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint("id", name="pk_profiles"), - sa.UniqueConstraint("username", name="uq_profiles_username"), - ) - op.create_index("ix_profiles_username", "profiles", ["username"]) - op.create_index("ix_profiles_deleted_at", "profiles", ["deleted_at"]) - - -def downgrade() -> None: - op.drop_index("ix_profiles_deleted_at", table_name="profiles") - op.drop_index("ix_profiles_username", table_name="profiles") - op.drop_table("profiles") diff --git a/backend/alembic/versions/20260224_bind_profiles_to_auth_users.py b/backend/alembic/versions/20260224_bind_profiles_to_auth_users.py deleted file mode 100644 index 123d22f..0000000 --- a/backend/alembic/versions/20260224_bind_profiles_to_auth_users.py +++ /dev/null @@ -1,112 +0,0 @@ -"""bind_profiles_to_auth_users - -Revision ID: 20260224_bind_profiles_auth -Revises: 85d25a191d06 -Create Date: 2026-02-24 17:55:00.000000 - -""" - -from typing import Sequence, Union - -from alembic import op - - -# revision identifiers, used by Alembic. -revision: str = "20260224_bind_profiles_auth" -down_revision: Union[str, Sequence[str], None] = "85d25a191d06" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Remove orphan profiles that are not backed by auth.users. - # This guarantees FK creation will not fail on historical inconsistent data. - op.execute( - """ - DELETE FROM public.profiles p - WHERE NOT EXISTS ( - SELECT 1 - FROM auth.users u - WHERE u.id = p.id - ) - """ - ) - - # Backfill profile rows for existing auth users. - op.execute( - """ - INSERT INTO public.profiles (id, username, display_name) - SELECT - u.id, - 'user_' || substr(replace(u.id::text, '-', ''), 1, 25), - COALESCE( - NULLIF(u.raw_user_meta_data->>'display_name', ''), - NULLIF(u.raw_user_meta_data->>'full_name', '') - ) - FROM auth.users u - LEFT JOIN public.profiles p ON p.id = u.id - WHERE p.id IS NULL - """ - ) - - # Enforce one-to-one binding between profiles.id and auth.users.id. - op.execute( - """ - ALTER TABLE public.profiles - ADD CONSTRAINT fk_profiles_id_auth_users - FOREIGN KEY (id) REFERENCES auth.users(id) - ON DELETE CASCADE - """ - ) - - # Auto-create profile rows when new auth users are registered. - op.execute( - """ - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public - AS $$ - BEGIN - INSERT INTO public.profiles (id, username, display_name) - VALUES ( - NEW.id, - 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 25), - COALESCE( - NULLIF(NEW.raw_user_meta_data->>'display_name', ''), - NULLIF(NEW.raw_user_meta_data->>'full_name', '') - ) - ) - ON CONFLICT (id) DO NOTHING; - - RETURN NEW; - END; - $$ - """ - ) - - op.execute( - """ - DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users - """ - ) - op.execute( - """ - CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users - FOR EACH ROW - EXECUTE FUNCTION public.create_profile_for_new_user() - """ - ) - - -def downgrade() -> None: - op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") - op.execute("DROP FUNCTION IF EXISTS public.create_profile_for_new_user()") - op.execute( - """ - ALTER TABLE public.profiles - DROP CONSTRAINT IF EXISTS fk_profiles_id_auth_users - """ - ) diff --git a/backend/alembic/versions/20260224_drop_profile_display_name_and_trigger_username.py b/backend/alembic/versions/20260224_drop_profile_display_name_and_trigger_username.py deleted file mode 100644 index 503888b..0000000 --- a/backend/alembic/versions/20260224_drop_profile_display_name_and_trigger_username.py +++ /dev/null @@ -1,121 +0,0 @@ -"""drop_profile_display_name_and_trigger_username - -Revision ID: 20260224_drop_profile -Revises: 20260224_bind_profiles_auth -Create Date: 2026-02-24 22:10:00.000000 - -""" - -from typing import Sequence, Union - -from alembic import op - - -revision: str = "20260224_drop_profile" -down_revision: Union[str, Sequence[str], None] = "20260224_bind_profiles_auth" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.execute( - """ - ALTER TABLE public.profiles - DROP CONSTRAINT IF EXISTS uq_profiles_username - """ - ) - - op.execute( - """ - ALTER TABLE public.profiles - DROP COLUMN IF EXISTS display_name - """ - ) - - op.execute( - """ - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public - AS $$ - BEGIN - INSERT INTO public.profiles (id, username) - VALUES ( - NEW.id, - COALESCE( - NULLIF(NEW.raw_user_meta_data->>'username', ''), - 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 25) - ) - ) - ON CONFLICT (id) DO NOTHING; - - RETURN NEW; - END; - $$ - """ - ) - - -def downgrade() -> None: - op.execute( - """ - ALTER TABLE public.profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(50) - """ - ) - - op.execute( - """ - WITH ranked AS ( - SELECT - id, - username, - row_number() OVER ( - PARTITION BY username - ORDER BY created_at ASC, id ASC - ) AS rn - FROM public.profiles - WHERE username IS NOT NULL - ) - UPDATE public.profiles p - SET username = LEFT(p.username, 24) || '_' || (ranked.rn - 1)::text - FROM ranked - WHERE p.id = ranked.id - AND ranked.rn > 1 - """ - ) - - op.execute( - """ - ALTER TABLE public.profiles - ADD CONSTRAINT uq_profiles_username UNIQUE (username) - """ - ) - - op.execute( - """ - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public - AS $$ - BEGIN - INSERT INTO public.profiles (id, username, display_name) - VALUES ( - NEW.id, - 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 25), - COALESCE( - NULLIF(NEW.raw_user_meta_data->>'display_name', ''), - NULLIF(NEW.raw_user_meta_data->>'full_name', '') - ) - ) - ON CONFLICT (id) DO NOTHING; - - RETURN NEW; - END; - $$ - """ - ) diff --git a/backend/alembic/versions/20260226_create_agent_chat_core_tables.py b/backend/alembic/versions/20260226_create_agent_chat_core_tables.py deleted file mode 100644 index a901255..0000000 --- a/backend/alembic/versions/20260226_create_agent_chat_core_tables.py +++ /dev/null @@ -1,195 +0,0 @@ -"""create_agent_chat_core_tables - -Revision ID: 20260226_agent_chat_core -Revises: 20260224_drop_profile -Create Date: 2026-02-26 10:00:00.000000 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - - -revision: str = "20260226_agent_chat_core" -down_revision: Union[str, Sequence[str], None] = "20260224_drop_profile" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "llm_factory", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("name", sa.String(length=50), nullable=False), - sa.Column("request_url", sa.String(length=255), nullable=False), - sa.Column("avatar", sa.Text(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint("id", name="pk_llm_factory"), - sa.UniqueConstraint("name", name="uq_llm_factory_name"), - ) - op.create_index("ix_llm_factory_name", "llm_factory", ["name"]) - op.create_index("ix_llm_factory_deleted_at", "llm_factory", ["deleted_at"]) - - op.create_table( - "llms", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("factory_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("model_code", sa.String(length=50), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ["factory_id"], ["llm_factory.id"], ondelete="RESTRICT" - ), - sa.PrimaryKeyConstraint("id", name="pk_llms"), - sa.UniqueConstraint("model_code", name="uq_llms_model_code"), - ) - op.create_index("ix_llms_factory_id", "llms", ["factory_id"]) - op.create_index("ix_llms_model_code", "llms", ["model_code"]) - op.create_index("ix_llms_deleted_at", "llms", ["deleted_at"]) - - op.create_table( - "sessions", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("title", sa.String(length=255), nullable=True), - sa.Column( - "status", - sa.Enum( - "pending", - "running", - "completed", - "failed", - name="agent_chat_session_status", - native_enum=False, - ), - nullable=False, - server_default="pending", - ), - sa.Column( - "last_activity_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"), - sa.Column("total_cost", sa.Numeric(12, 6), nullable=False, server_default="0"), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id", name="pk_sessions"), - ) - op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) - op.create_index( - "ix_sessions_user_last_activity", - "sessions", - ["user_id", "last_activity_at"], - ) - op.create_index("ix_sessions_deleted_at", "sessions", ["deleted_at"]) - - op.create_table( - "messages", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("session_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("seq", sa.Integer(), nullable=False), - sa.Column( - "role", - sa.Enum( - "user", - "assistant", - "system", - "tool", - name="agent_chat_message_role", - native_enum=False, - ), - nullable=False, - ), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("model_code", sa.String(length=50), nullable=True), - sa.Column("tool_name", sa.String(length=100), nullable=True), - sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"), - sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"), - sa.Column("cost", sa.Numeric(12, 6), nullable=False, server_default="0"), - sa.Column( - "currency", sa.String(length=3), nullable=False, server_default="USD" - ), - sa.Column("latency_ms", sa.Integer(), nullable=True), - sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id", name="pk_messages"), - sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"), - ) - op.create_index("ix_messages_session_id", "messages", ["session_id"]) - op.create_index("ix_messages_session_role", "messages", ["session_id", "role"]) - op.create_index("ix_messages_deleted_at", "messages", ["deleted_at"]) - - -def downgrade() -> None: - op.drop_index("ix_messages_deleted_at", table_name="messages") - op.drop_index("ix_messages_session_role", table_name="messages") - op.drop_index("ix_messages_session_id", table_name="messages") - op.drop_table("messages") - - op.drop_index("ix_sessions_deleted_at", table_name="sessions") - op.drop_index("ix_sessions_user_last_activity", table_name="sessions") - op.drop_index("ix_sessions_user_id", table_name="sessions") - op.drop_table("sessions") - - op.drop_index("ix_llms_deleted_at", table_name="llms") - op.drop_index("ix_llms_model_code", table_name="llms") - op.drop_index("ix_llms_factory_id", table_name="llms") - op.drop_table("llms") - - op.drop_index("ix_llm_factory_deleted_at", table_name="llm_factory") - op.drop_index("ix_llm_factory_name", table_name="llm_factory") - op.drop_table("llm_factory") diff --git a/backend/alembic/versions/20260226_enable_rls_for_agent_chat_tables.py b/backend/alembic/versions/20260226_enable_rls_for_agent_chat_tables.py deleted file mode 100644 index 1d99fa3..0000000 --- a/backend/alembic/versions/20260226_enable_rls_for_agent_chat_tables.py +++ /dev/null @@ -1,58 +0,0 @@ -"""enable_rls_for_agent_chat_tables - -Revision ID: 20260226_agent_chat_rls -Revises: 20260226_agent_chat_core -Create Date: 2026-02-26 18:00:00.000000 - -""" - -from typing import Sequence, Union - -from alembic import op - - -revision: str = "20260226_agent_chat_rls" -down_revision: Union[str, Sequence[str], None] = "20260226_agent_chat_core" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -TABLES = ("llm_factory", "llms", "sessions", "messages") - - -def _enable_rls_and_deny_public(table: str) -> None: - op.execute(f"ALTER TABLE public.{table} ENABLE ROW LEVEL SECURITY") - op.execute( - f"CREATE POLICY {table}_deny_public_select ON public.{table} " - "FOR SELECT TO anon, authenticated USING (false)" - ) - op.execute( - f"CREATE POLICY {table}_deny_public_insert ON public.{table} " - "FOR INSERT TO anon, authenticated WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {table}_deny_public_update ON public.{table} " - "FOR UPDATE TO anon, authenticated USING (false) WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {table}_deny_public_delete ON public.{table} " - "FOR DELETE TO anon, authenticated USING (false)" - ) - - -def _disable_rls_and_drop_policies(table: str) -> None: - op.execute(f"DROP POLICY IF EXISTS {table}_deny_public_select ON public.{table}") - op.execute(f"DROP POLICY IF EXISTS {table}_deny_public_insert ON public.{table}") - op.execute(f"DROP POLICY IF EXISTS {table}_deny_public_update ON public.{table}") - op.execute(f"DROP POLICY IF EXISTS {table}_deny_public_delete ON public.{table}") - op.execute(f"ALTER TABLE public.{table} DISABLE ROW LEVEL SECURITY") - - -def upgrade() -> None: - for table in TABLES: - _enable_rls_and_deny_public(table) - - -def downgrade() -> None: - for table in reversed(TABLES): - _disable_rls_and_drop_policies(table) diff --git a/backend/alembic/versions/85d25a191d06_enable_rls_security_policies.py b/backend/alembic/versions/85d25a191d06_enable_rls_security_policies.py deleted file mode 100644 index c4988df..0000000 --- a/backend/alembic/versions/85d25a191d06_enable_rls_security_policies.py +++ /dev/null @@ -1,86 +0,0 @@ -"""enable_rls_security_policies - -Revision ID: 85d25a191d06 -Revises: 20260205_create_profiles_table -Create Date: 2026-02-05 15:08:33.430692 - -""" - -from typing import Sequence, Union - -from alembic import op - - -# revision identifiers, used by Alembic. -revision: str = "85d25a191d06" -down_revision: Union[str, Sequence[str], None] = "20260205_create_profiles_table" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Enable RLS security policies. - - Security measures: - 1. Revoke anon role access to alembic_version (internal table) - 2. Enable RLS on profiles table - 3. Add defensive policies for profiles (deny all public access by default) - - Architecture: - - Backend uses service_role connection (bypasses RLS) - - RLS provides defense-in-depth security layer - - Prevents accidental direct PostgREST access - """ - - # 1. Revoke anon role access to alembic_version table - op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon") - op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated") - - # 2. Enable RLS on profiles table - op.execute("ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY") - - # 3. Add defensive policies for profiles table - # These policies deny all public access by default - # Backend service_role connection bypasses these policies - - # Deny all SELECT operations for anon and authenticated roles - op.execute( - "CREATE POLICY profiles_deny_public_select ON public.profiles " - "FOR SELECT TO anon, authenticated USING (false)" - ) - - # Deny all INSERT operations for anon and authenticated roles - op.execute( - "CREATE POLICY profiles_deny_public_insert ON public.profiles " - "FOR INSERT TO anon, authenticated WITH CHECK (false)" - ) - - # Deny all UPDATE operations for anon and authenticated roles - op.execute( - "CREATE POLICY profiles_deny_public_update ON public.profiles " - "FOR UPDATE TO anon, authenticated USING (false) WITH CHECK (false)" - ) - - # Deny all DELETE operations for anon and authenticated roles - op.execute( - "CREATE POLICY profiles_deny_public_delete ON public.profiles " - "FOR DELETE TO anon, authenticated USING (false)" - ) - - -def downgrade() -> None: - """Rollback RLS security policies.""" - - # 1. Drop all policies on profiles table - op.execute("DROP POLICY IF EXISTS profiles_deny_public_select ON public.profiles") - op.execute("DROP POLICY IF EXISTS profiles_deny_public_insert ON public.profiles") - op.execute("DROP POLICY IF EXISTS profiles_deny_public_update ON public.profiles") - op.execute("DROP POLICY IF EXISTS profiles_deny_public_delete ON public.profiles") - - # 2. Disable RLS on profiles table - op.execute("ALTER TABLE public.profiles DISABLE ROW LEVEL SECURITY") - - # 3. Re-grant default privileges to anon role on alembic_version - # (reverting to Alembic's default behavior) - op.execute("GRANT SELECT ON TABLE public.alembic_version TO anon") - op.execute("GRANT SELECT ON TABLE public.alembic_version TO authenticated")