Explorar o código

Fixes #23130: Ensure ltree cascade triggers can be restored from a pg_dump

The AFTER triggers which cascade a hierarchical object's path to its
descendants gated themselves on `OLD.path IS DISTINCT FROM NEW.path`.
`IS DISTINCT FROM` is SQL grammar rather than a schema-qualifiable
operator: it expands to the operand type's `=` operator, resolved from
search_path at CREATE TRIGGER time. The ltree extension installs into
`public`, so a CREATE TRIGGER replayed by pg_restore -- which sets
search_path to the empty string and schema-qualifies every name it can --
could not resolve `ltree = ltree` and failed.

Because psql does not stop on error by default, restoring a v4.7.0 dump
reported success while silently omitting all 11 cascade triggers. Renaming
or moving a group object then left its descendants' path and sort_path
stale, with no error surfaced.

Comparing the paths as text resolves `pg_catalog.text =` instead, which is
always available. The comparison is equivalent because ltree's text I/O is
byte-preserving and ltree_eq is a memcmp over the same bytes.

Also reinstalls the triggers on existing databases, which carry either the
old definition (upgraded in place) or no cascade trigger at all (restored
from a dump), and makes InstallLtreeTriggers idempotent so it can be
re-run. Adds -v ON_ERROR_STOP=1 to the documented restore procedure, which
is what allowed the failure to go unnoticed.
Jason Novinger hai 13 horas
pai
achega
f6c4d69e07

+ 8 - 1
docs/administration/replicating-netbox.md

@@ -34,9 +34,16 @@ When restoring a database from a file, it's recommended to delete any existing d
 ```no-highlight
 psql -c 'drop database netbox'
 psql -c 'create database netbox'
-psql netbox < netbox.sql
+psql -v ON_ERROR_STOP=1 netbox < netbox.sql
 ```
 
+!!! warning "Always restore with ON_ERROR_STOP"
+    By default, `psql` continues after an error and still exits with status 0. A restore which failed partway through, leaving out an index, a function, or a trigger, therefore reports success and yields a database which looks healthy but is incomplete. Passing `-v ON_ERROR_STOP=1` makes `psql` abort on the first error and exit non-zero, so check the exit status before putting the restored database into service.
+
+    This changes the behavior of the restore: a dump which previously appeared to restore successfully will now abort on its first error, including errors unrelated to NetBox's own schema (a role which already exists, an extension owned by another user, and so on). That is the intended outcome, but expect a restore which used to "succeed" to start reporting failures which were there all along.
+
+    For a dump in one of `pg_dump`'s non-plain formats, restore it with `pg_restore --exit-on-error` instead.
+
 Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](../installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway.
 
 ### Export the Database Schema

+ 31 - 0
docs/release-notes/version-4.7.md

@@ -1,5 +1,36 @@
 # NetBox v4.7
 
+## v4.7.1 (FUTURE)
+
+!!! warning "Databases Restored From a v4.7.0 Dump"
+    The triggers which cascade a hierarchical object's path to its descendants could not be recreated when restoring a `pg_dump` of a v4.7.0 database, because `pg_dump` resets the `search_path` and the triggers' `WHEN` clause depended on it. As `psql` does not stop on error by default, such a restore reported success while leaving the database without those triggers, so renaming or moving a region, site group, location, device role, platform, tenant group, contact group, wireless LAN group, module bay, or inventory item did not update its descendants.
+
+    Upgrading reinstalls the triggers, so all subsequent changes are cascaded correctly. It does **not** repair values which have already gone stale. The query below reports whether a table is affected. Substitute each hierarchical table in turn: `dcim_region`, `dcim_sitegroup`, `dcim_location`, `dcim_devicerole`, `dcim_platform`, `dcim_modulebay`, `dcim_inventoryitem`, `dcim_inventoryitemtemplate`, `tenancy_tenantgroup`, `tenancy_contactgroup`, and `wireless_wirelesslangroup`.
+
+    ```no-highlight
+    SELECT count(*) FROM dcim_region c JOIN dcim_region p ON c.parent_id = p.id
+    WHERE c.path <> p.path || lpad(c.id::text, 19, '0')::ltree;
+    ```
+
+    Treat any non-zero result as "this table needs rebuilding" rather than as a count of the damage: a node whose ancestor moved is reported, but its own descendants are consistent with it and so are not, even though they are equally stale.
+
+    The nine tables which order their children by name additionally maintain a `sort_path`, which can go stale on a rename even when `path` is correct. Every table in the list above except `dcim_inventoryitem` and `dcim_inventoryitemtemplate` carries one, and is checked with:
+
+    ```no-highlight
+    SELECT count(*) FROM dcim_region c JOIN dcim_region p ON c.parent_id = p.id
+    WHERE c.sort_path <> p.sort_path || chr(9) || c.name;
+    ```
+
+    Stale `sort_path` values affect only the order in which objects are listed. A stale `path`, by contrast, misplaces an object within the hierarchy, so it can be omitted from its ancestor's list of descendants.
+
+    Plugins which maintain their own `ltree` models via the `InstallLtreeTriggers` migration operation are affected in the same way, and their tables are not touched by the migrations above. Where such a database was restored from a dump, the plugin's cascade triggers are missing entirely; where it was upgraded in place, they carry the old definition and will be lost by its next dump. Either way, applying `InstallLtreeTriggers` again from a new plugin migration reinstalls them: as of this release the operation drops each trigger before recreating it, so it is safe to re-run.
+
+### Bug Fixes
+
+* [#23130](https://github.com/netbox-community/netbox/issues/23130) - Ensure the triggers which cascade hierarchical paths to descendants can be restored from a `pg_dump`
+
+---
+
 ## v4.7.0 (2026-09-02)
 
 !!! warning "PostgreSQL 15 or Later Required"

+ 47 - 0
netbox/dcim/migrations/0251_fix_ltree_cascade_triggers.py

@@ -0,0 +1,47 @@
+"""Reinstall the ltree cascade triggers with a restore-safe WHEN clause.
+
+The cascade triggers installed by 0242_ltree_paths compared two ltree values with
+`IS DISTINCT FROM`, which resolves the `ltree = ltree` operator through search_path at
+CREATE TRIGGER time. pg_dump emits `set_config('search_path', '', false)`, so restoring a
+v4.7.0 dump could not create these triggers — and because psql does not stop on error by
+default, the restore reported success with the triggers silently missing. See #23130.
+
+Reinstalling covers both affected databases: one restored from such a dump (the triggers
+are absent) and one upgraded in place (they exist with the old definition, which would
+fail its own next restore). InstallLtreeTriggers drops before creating, so this applies
+cleanly in either state.
+
+This reinstalls triggers only. It does not touch table data, so it does not incur the
+table-wide lock that 0242's path backfill did, and it does not repair path/sort_path
+values which went stale while the triggers were missing; see the v4.7.1 release notes for
+detection and repair.
+
+Reversing this migration is a no-op: the triggers it replaces belong to 0242_ltree_paths,
+which recreates them (from the corrected template) when reversed in turn.
+"""
+from django.db import migrations
+
+from utilities.ltree import ReinstallLtreeTriggers
+
+# The tables carrying a sort_path column, maintained from `name`.
+SORT_TABLES = (
+    'dcim_region',
+    'dcim_sitegroup',
+    'dcim_location',
+    'dcim_devicerole',
+    'dcim_platform',
+    'dcim_modulebay',
+)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0250_cooling_infrastructure'),
+    ]
+
+    operations = [
+        *[ReinstallLtreeTriggers(t, name_column='name') for t in SORT_TABLES],
+        ReinstallLtreeTriggers('dcim_inventoryitem'),
+        ReinstallLtreeTriggers('dcim_inventoryitemtemplate'),
+    ]

+ 35 - 0
netbox/tenancy/migrations/0027_fix_ltree_cascade_triggers.py

@@ -0,0 +1,35 @@
+"""Reinstall the ltree cascade triggers with a restore-safe WHEN clause.
+
+The cascade triggers installed by 0025_ltree_paths compared two ltree values with
+`IS DISTINCT FROM`, which resolves the `ltree = ltree` operator through search_path at
+CREATE TRIGGER time. pg_dump emits `set_config('search_path', '', false)`, so restoring a
+v4.7.0 dump could not create these triggers — and because psql does not stop on error by
+default, the restore reported success with the triggers silently missing. See #23130.
+
+Reinstalling covers both affected databases: one restored from such a dump (the triggers
+are absent) and one upgraded in place (they exist with the old definition, which would
+fail its own next restore). InstallLtreeTriggers drops before creating, so this applies
+cleanly in either state.
+
+This does not repair path/sort_path values which went stale while the triggers were
+missing; see the v4.7.1 release notes for detection and repair.
+
+Reversing this migration is a no-op: the triggers it replaces belong to 0025_ltree_paths,
+which recreates them (from the corrected template) when reversed in turn.
+"""
+from django.db import migrations
+
+from utilities.ltree import ReinstallLtreeTriggers
+
+TABLES = ('tenancy_tenantgroup', 'tenancy_contactgroup')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0026_consolidate_unique_constraints'),
+    ]
+
+    operations = [
+        *[ReinstallLtreeTriggers(t, name_column='name') for t in TABLES],
+    ]

+ 129 - 20
netbox/utilities/ltree.py

@@ -9,11 +9,43 @@ not on the model definitions.
 
 The paths maintained by these triggers are never computed or mutated from Python;
 the model layer only reads `path`/`sort_path` back from the database.
+
+Trigger DDL and search_path
+---------------------------
+Everything this module emits is replayed verbatim by `pg_restore`, which runs with
+`search_path` set to the empty string and schema-qualifies every name it can (a
+CVE-2018-1058 hardening). Unqualified names in the SQL below therefore have to
+resolve without help from the path, and the two halves of a trigger differ in when
+that resolution happens:
+
+* A trigger's WHEN clause is resolved at CREATE TRIGGER time. `IS DISTINCT FROM`
+  (like `=`, `<`, ...) is grammar which expands to the operand type's operator, and
+  there is no syntax to schema-qualify it. An extension type whose operators live
+  outside `pg_catalog` — `ltree` installs into `public` — makes that CREATE TRIGGER
+  unrestorable, and because `psql` does not stop on error by default the restore
+  appears to succeed with the trigger silently missing (#23130).
+* A trigger FUNCTION's body survives only because pg_dump emits
+  `SET check_function_bodies = false`, which suppresses the validation that would
+  otherwise reject the unqualified `ltree` declarations below at CREATE FUNCTION
+  time. Under the default `check_function_bodies = on` they fail with
+  `type "ltree" does not exist`. So the bodies are not inherently path-independent;
+  they are exempted by the restore's own configuration. Anything replaying this DDL
+  outside a pg_dump context must either put the extension's schema on the path or
+  set that GUC itself.
+
+    Rule: keep WHEN-clause operand types inside `pg_catalog`. Cast an
+    extension-typed column with `::text` (ltree's text I/O is byte-canonical, so
+    `::text` equality is exactly ltree equality).
+
+`RestoreUnderRestrictedSearchPathTests` in `utilities/tests/test_ltree.py` enforces
+this by creating the generated DDL with the extension's schema off the search_path.
 """
 from django.db import migrations
 
 __all__ = (
     'InstallLtreeTriggers',
+    'ReinstallLtreeTriggers',
+    'ltree_trigger_sql',
 )
 
 
@@ -220,10 +252,35 @@ CREATE TRIGGER "{table}_ltree_compute_path"
 # because that statement does not touch parent_id or {name_col}, the AFTER
 # trigger does not re-fire on those descendant rows. This prevents the
 # quadratic re-cascade that would otherwise occur for any deep subtree.
+#
+# `path` is compared as text rather than as ltree. `IS DISTINCT FROM` is SQL
+# grammar, not an operator: it has no schema-qualification syntax, and it expands
+# to the operand type's `=` operator, which is resolved from search_path at
+# CREATE TRIGGER time. `ltree =` lives in whichever schema the extension was
+# installed into (normally `public`), so a CREATE TRIGGER replayed by pg_restore
+# — which runs with `search_path` set to the empty string and schema-qualifies
+# every name it can — cannot resolve it and fails with "operator does not exist:
+# public.ltree = public.ltree", silently dropping the cascade trigger from the
+# restored database (#23130). `text =` is in `pg_catalog`, which is always on the
+# effective path, so the cast makes the DDL search_path-independent.
+#
+# The comparison is equivalent: ltree's text I/O is byte-preserving (parse_ltree
+# and deparse_ltree copy label bytes with memcpy, and ltree_eq is a memcmp over
+# those same bytes), so two ltree values are equal iff their text renderings are
+# — see contrib/ltree/ltree_io.c and ltree_op.c. PostgreSQL publishes no explicit
+# guarantee of this; it is a property of the implementation, which cannot change
+# without breaking ltree's on-disk format and every existing ltree index.
+#
+# `sort_path` needs no cast: it is already a text column.
+#
+# See also the module docstring ("Trigger DDL and search_path") and
+# RestoreUnderRestrictedSearchPathTests in utilities/tests/test_ltree.py, which
+# enforces this by creating the generated DDL with the extension's schema off the
+# search_path.
 _AFTER_TRIGGER_PATH_ONLY = '''
 CREATE TRIGGER "{table}_ltree_cascade_path"
     AFTER UPDATE OF parent_id ON "{table}"
-    FOR EACH ROW WHEN (OLD.path IS DISTINCT FROM NEW.path)
+    FOR EACH ROW WHEN (OLD.path::text IS DISTINCT FROM NEW.path::text)
     EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
 '''
 
@@ -231,13 +288,57 @@ _AFTER_TRIGGER_PATH_AND_SORT = '''
 CREATE TRIGGER "{table}_ltree_cascade_path"
     AFTER UPDATE OF parent_id, "{name_col}" ON "{table}"
     FOR EACH ROW WHEN (
-        OLD.path IS DISTINCT FROM NEW.path
+        OLD.path::text IS DISTINCT FROM NEW.path::text
         OR OLD.sort_path IS DISTINCT FROM NEW.sort_path
     )
     EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
 '''
 
 
+def ltree_trigger_sql(table, name_column=None):
+    """
+    Return the DDL statements which install ltree path-maintenance triggers on `table`.
+
+    Two functions and two triggers, in dependency order. If `name_column` is given, the
+    table is expected to carry a `sort_path` column and gets the variants which maintain
+    it alongside `path`.
+
+    The triggers are dropped before being created, so re-running this SQL converges
+    instead of failing: a plain `CREATE TRIGGER` raises 42710 when the trigger already
+    exists, which a re-run, a partially-applied migration, or a later migration
+    reinstalling a corrected definition (#23130) would all hit. The functions already use
+    CREATE OR REPLACE. This mirrors `utilities.migration.InstallDenormalizationTrigger`.
+
+    `InstallLtreeTriggers` executes exactly this SQL, so tests can assert against the
+    statements migrations really run rather than a copy which can drift.
+    """
+    if name_column:
+        function_sql = (
+            _COMPUTE_PATH_AND_SORT_FN.format(table=table, name_col=name_column),
+            _CASCADE_PATH_AND_SORT_FN.format(table=table),
+        )
+        trigger_sql = (
+            _BEFORE_TRIGGER_PATH_AND_SORT.format(table=table, name_col=name_column),
+            _AFTER_TRIGGER_PATH_AND_SORT.format(table=table, name_col=name_column),
+        )
+    else:
+        function_sql = (
+            _COMPUTE_PATH_ONLY_FN.format(table=table),
+            _CASCADE_PATH_ONLY_FN.format(table=table),
+        )
+        trigger_sql = (
+            _BEFORE_TRIGGER_PATH_ONLY.format(table=table),
+            _AFTER_TRIGGER_PATH_ONLY.format(table=table),
+        )
+
+    return (
+        *function_sql,
+        f'DROP TRIGGER IF EXISTS "{table}_ltree_cascade_path" ON "{table}";',
+        f'DROP TRIGGER IF EXISTS "{table}_ltree_compute_path" ON "{table}";',
+        *trigger_sql,
+    )
+
+
 class InstallLtreeTriggers(migrations.operations.base.Operation):
     """
     Install per-table ltree path-maintenance triggers.
@@ -252,6 +353,10 @@ class InstallLtreeTriggers(migrations.operations.base.Operation):
     ancestor names. This implements MPTT's `order_insertion_by=(name,)`
     semantics: insert, reparent, and rename all honor the current value of
     `name_column`, with renames cascaded into descendants' sort_paths.
+
+    Applying this operation is idempotent (see `ltree_trigger_sql`), so it can be
+    re-run to reinstall a corrected trigger definition on a table which already has
+    one.
     """
     reversible = True
 
@@ -263,24 +368,8 @@ class InstallLtreeTriggers(migrations.operations.base.Operation):
         pass
 
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
-        if self.name_column:
-            schema_editor.execute(_COMPUTE_PATH_AND_SORT_FN.format(
-                table=self.table_name, name_col=self.name_column,
-            ))
-            schema_editor.execute(_CASCADE_PATH_AND_SORT_FN.format(
-                table=self.table_name,
-            ))
-            schema_editor.execute(_BEFORE_TRIGGER_PATH_AND_SORT.format(
-                table=self.table_name, name_col=self.name_column,
-            ))
-            schema_editor.execute(_AFTER_TRIGGER_PATH_AND_SORT.format(
-                table=self.table_name, name_col=self.name_column,
-            ))
-        else:
-            schema_editor.execute(_COMPUTE_PATH_ONLY_FN.format(table=self.table_name))
-            schema_editor.execute(_CASCADE_PATH_ONLY_FN.format(table=self.table_name))
-            schema_editor.execute(_BEFORE_TRIGGER_PATH_ONLY.format(table=self.table_name))
-            schema_editor.execute(_AFTER_TRIGGER_PATH_ONLY.format(table=self.table_name))
+        for sql in ltree_trigger_sql(self.table_name, self.name_column):
+            schema_editor.execute(sql)
 
     def database_backwards(self, app_label, schema_editor, from_state, to_state):
         t = self.table_name
@@ -291,3 +380,23 @@ class InstallLtreeTriggers(migrations.operations.base.Operation):
 
     def describe(self):
         return f"Install ltree path triggers on {self.table_name}"
+
+
+class ReinstallLtreeTriggers(InstallLtreeTriggers):
+    """
+    Reinstall a table's ltree path-maintenance triggers, replacing an earlier definition.
+
+    Identical to `InstallLtreeTriggers` going forwards, but a no-op in reverse. The
+    parent operation's reverse drops both triggers and both functions, which is right
+    when reversing the migration that first installed them and wrong when reversing one
+    that merely corrected them: it would leave the table with no path maintenance at all
+    — a state no release ever shipped — and every subsequent INSERT failing on `path`'s
+    NOT NULL constraint. The triggers this replaces are recreated by reversing back to
+    the migration which installed them, so there is nothing for this operation to undo.
+    """
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        pass
+
+    def describe(self):
+        return f"Reinstall ltree path triggers on {self.table_name}"

+ 10 - 0
netbox/utilities/migration.py

@@ -67,6 +67,16 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
     newly created source row has no dependents yet) and it does not recurse: the dependent tables carry no
     triggers of their own.
 
+    !!! warning "Watched columns must be of a type whose `=` lives in `pg_catalog`"
+        The generated WHEN clause compares each watched column with `IS DISTINCT FROM`, which expands to
+        that column type's `=` operator, resolved from `search_path` at CREATE TRIGGER time and with no
+        syntax available to schema-qualify it. Every current caller watches integer FK columns, whose `=` is
+        a built-in in `pg_catalog` and therefore always resolvable. Do NOT pass a column of an extension
+        type (`ltree`, `hstore`, PostGIS `geometry`, ...): its operators live in the extension's schema, so
+        the resulting trigger would fail to restore from a `pg_dump`, which replays DDL with an empty
+        `search_path` — and because `psql` does not stop on error by default, the restore would appear to
+        succeed with the trigger silently missing. See `utilities/ltree.py` and #23130.
+
     Example: refresh a CircuitTermination's cached region/sitegroup when its Site's region or group changes::
 
         InstallDenormalizationTrigger(

+ 136 - 1
netbox/utilities/tests/test_ltree.py

@@ -1,11 +1,15 @@
 """Tests for the ltree-based hierarchical model infrastructure."""
+from django.apps import apps
 from django.contrib.contenttypes.models import ContentType
 from django.db import connection
-from django.test import TestCase
+from django.test import SimpleTestCase, TestCase
 
 from core.models import ObjectChange
 from dcim.models import Region, Site
+from netbox.models.ltree import LtreeModel
+from netbox.plugins import PluginConfig
 from tenancy.models import Contact, ContactGroup
+from utilities.ltree import ltree_trigger_sql
 from utilities.mptt_to_ltree import populate_paths_sql
 
 
@@ -992,3 +996,134 @@ class RestrictedSearchPathBackfillTests(TestCase):
 
         self.assertEqual(len(plain_rows), 2)
         self.assertEqual([r[0] for r in plain_rows], [_path(1), _path(1, 2)])
+
+
+class RestoreUnderRestrictedSearchPathTests(TestCase):
+    """
+    The generated trigger DDL must create with the ltree extension's schema off the
+    search_path, because that is how pg_dump replays it: dumps begin with
+    `set_config('search_path', '', false)` and schema-qualify every name they can.
+
+    `IS DISTINCT FROM` cannot be schema-qualified — it expands to the operand type's
+    `=` operator, resolved at CREATE TRIGGER time — so comparing two ltree values in a
+    WHEN clause produced a cascade trigger which silently failed to restore, leaving
+    descendant paths to go stale on the next rename or reparent (#23130). Comparing
+    `path::text` resolves `pg_catalog.text =` instead, which is always available.
+
+    The trigger functions are created with the extension's schema on the path: they
+    legitimately declare `parent_path ltree`, and pg_dump schema-qualifies those
+    declarations, so only the CREATE TRIGGER statements are under test here.
+    """
+
+    def _install_with_extension_off_path(self, schema, table, name_column):
+        with connection.cursor() as cursor:
+            cursor.execute(f'CREATE SCHEMA {schema}')
+            columns = 'id bigint PRIMARY KEY, parent_id bigint, path ltree, name text'
+            if name_column:
+                columns += ', sort_path text'
+            cursor.execute(f'SET LOCAL search_path = {schema}, public')
+            cursor.execute(f'CREATE TABLE {schema}.{table} ({columns})')
+
+            statements = ltree_trigger_sql(table, name_column)
+            functions = [s for s in statements if 'CREATE OR REPLACE FUNCTION' in s]
+            triggers = [s for s in statements if 'CREATE TRIGGER' in s]
+            self.assertEqual(len(functions), 2)
+            self.assertEqual(len(triggers), 2)
+
+            # Pass an empty parameter list, as schema_editor.execute() does during a
+            # migration: the function bodies double their literal percent signs for
+            # .format(), and psycopg only collapses `%%` to `%` when parameters are
+            # given. Executing them without it fails to compile the plpgsql.
+            for statement in functions:
+                cursor.execute(statement, ())
+
+            # Drop the extension's schema, as a pg_dump restore does, and create only
+            # the triggers.
+            cursor.execute(f'SET LOCAL search_path = {schema}')
+            for statement in triggers:
+                cursor.execute(statement, ())
+
+            cursor.execute(
+                'SELECT tgname FROM pg_trigger t '
+                'JOIN pg_class c ON t.tgrelid = c.oid '
+                'JOIN pg_namespace n ON c.relnamespace = n.oid '
+                'WHERE n.nspname = %s AND NOT t.tgisinternal ORDER BY tgname',
+                [schema],
+            )
+            return [row[0] for row in cursor.fetchall()]
+
+    def test_path_and_sort_triggers_create_with_extension_off_search_path(self):
+        installed = self._install_with_extension_off_path('sp_sorted', 'sorted', 'name')
+        self.assertEqual(installed, ['sorted_ltree_cascade_path', 'sorted_ltree_compute_path'])
+
+    def test_path_only_triggers_create_with_extension_off_search_path(self):
+        installed = self._install_with_extension_off_path('sp_plain', 'plain', None)
+        self.assertEqual(installed, ['plain_ltree_cascade_path', 'plain_ltree_compute_path'])
+
+
+class CascadeTriggerDefinitionTests(TestCase):
+    """
+    Every core LtreeModel's cascade trigger must compare `path` as text.
+
+    Asserting on the definitions stored in the database, rather than on the templates,
+    also covers the set of tables: a tree table missing from the migrations which
+    reinstalled these triggers is reported by name here. Triggers are not part of
+    Django's model state, so `makemigrations --check` cannot detect that drift.
+
+    The expected tables are derived from the model layer and plugin models are excluded,
+    so installing a plugin with its own ltree model cannot fail this.
+    """
+
+    @staticmethod
+    def _core_ltree_tables():
+        def concrete_subclasses(base):
+            for subclass in base.__subclasses__():
+                if subclass._meta.abstract:
+                    yield from concrete_subclasses(subclass)
+                elif not isinstance(apps.get_app_config(subclass._meta.app_label), PluginConfig):
+                    yield subclass
+
+        return {model._meta.db_table for model in concrete_subclasses(LtreeModel)}
+
+    def test_core_cascade_triggers_compare_path_as_text(self):
+        expected = self._core_ltree_tables()
+        self.assertTrue(expected, 'no core LtreeModel subclasses found')
+
+        with connection.cursor() as cursor:
+            cursor.execute(
+                'SELECT c.relname, pg_get_triggerdef(t.oid) FROM pg_trigger t '
+                'JOIN pg_class c ON t.tgrelid = c.oid '
+                'WHERE NOT t.tgisinternal AND t.tgname = c.relname || %s',
+                ['_ltree_cascade_path'],
+            )
+            definitions = dict(cursor.fetchall())
+
+        self.assertSetEqual(
+            expected - set(definitions), set(),
+            msg='these core ltree tables have no cascade trigger installed',
+        )
+        for table in sorted(expected):
+            self.assertIn(
+                '(old.path)::text IS DISTINCT FROM (new.path)::text', definitions[table],
+                msg=f'{table}: the cascade trigger compares ltree values directly, so it '
+                    f'will not survive a pg_dump restore (see #23130)',
+            )
+
+
+class LtreeTriggerSqlTests(SimpleTestCase):
+    """The generated cascade DDL must not compare ltree values directly (#23130)."""
+
+    def test_cascade_when_clause_casts_path_to_text(self):
+        for name_column in ('name', None):
+            with self.subTest(name_column=name_column):
+                sql = '\n'.join(ltree_trigger_sql('probe', name_column))
+                self.assertIn('OLD.path::text IS DISTINCT FROM NEW.path::text', sql)
+                self.assertNotIn('OLD.path IS DISTINCT FROM NEW.path', sql)
+
+    def test_triggers_are_dropped_before_creation(self):
+        sql = ltree_trigger_sql('probe', 'name')
+        for trigger in ('probe_ltree_cascade_path', 'probe_ltree_compute_path'):
+            drop = f'DROP TRIGGER IF EXISTS "{trigger}" ON "probe";'
+            self.assertIn(drop, sql)
+            create = next(s for s in sql if f'CREATE TRIGGER "{trigger}"' in s)
+            self.assertLess(sql.index(drop), sql.index(create))

+ 35 - 0
netbox/wireless/migrations/0024_fix_ltree_cascade_triggers.py

@@ -0,0 +1,35 @@
+"""Reinstall the ltree cascade trigger with a restore-safe WHEN clause.
+
+The cascade trigger installed by 0021_ltree_paths compared two ltree values with
+`IS DISTINCT FROM`, which resolves the `ltree = ltree` operator through search_path at
+CREATE TRIGGER time. pg_dump emits `set_config('search_path', '', false)`, so restoring a
+v4.7.0 dump could not create this trigger — and because psql does not stop on error by
+default, the restore reported success with the trigger silently missing. See #23130.
+
+Reinstalling covers both affected databases: one restored from such a dump (the trigger is
+absent) and one upgraded in place (the trigger exists with the old definition, which would
+fail its own next restore). InstallLtreeTriggers drops before creating, so this applies
+cleanly in either state.
+
+This does not repair path/sort_path values which went stale while the trigger was missing;
+see the v4.7.1 release notes for detection and repair.
+
+Reversing this migration is a no-op: the trigger it replaces belongs to 0021_ltree_paths,
+which recreates it (from the corrected template) when reversed in turn.
+"""
+from django.db import migrations
+
+from utilities.ltree import ReinstallLtreeTriggers
+
+TABLE = 'wireless_wirelesslangroup'
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wireless', '0023_wirelesslangroup_drop_unique_constraint'),
+    ]
+
+    operations = [
+        ReinstallLtreeTriggers(TABLE, name_column='name'),
+    ]