Repository: edgedb/edgedb

Issue:
prohibit mutation of schema::Migration
`schema::Migration` should be read-only. Currently it allows `DELETE` (and `INSERT` seems to be allowed to as long as you can compute the hash, I think). We should prohibit any modifications inside the `schema::` namespace with DML.


cc @ambv 


Discussion:
Deletion of `schema::Migration` is used in single-db tests to nuke the database (added [here](https://github.com/edgedb/edgedb/pull/3249/commits/d382874bd92dca3c5109f39c97c00b5f1fd79e5a)), we probably want to use the future version of wipe-db for those tests instead.
Hey, would you mind providing some more information on this issue (at a new contributor level of understanding).

Also, could you provide a test case that triggers this behavior?
`delete schema::Migration` ought to produce a nice error message. Currently, it succeeds. Fortunately, it never actually deletes anything, because there is an access policy on it that causes objects to not be visible to the delete.

Additionally and closely relatedly, `delete schema::Object` (and update, insert) will produce an internal server error, as it compiles into broken SQL for various reasons.

We ought to do a proper error check for all of these "mutating objects in the standard library" cases and produce a proper error message.

`edb/edgeql/compiler/stmt.py` is the place where statements get compiled from the AST to our IR, and is where the checks should happen

Resolving patch:
diff --git a/edb/edgeql/compiler/options.py b/edb/edgeql/compiler/options.py
--- a/edb/edgeql/compiler/options.py
+++ b/edb/edgeql/compiler/options.py
@@ -94,6 +94,12 @@ class GlobalCompilerOptions:
     #: Is the compiler running in testmode
     testmode: bool = False
 
+    # Is the compiler running in the server's schema reflection mode
+    schema_reflection_mode: bool = False
+
+    # are we invoking the compiler from inside a CONFIGURE?
+    in_server_config_op: bool = False
+
 
 @dataclass
 class CompilerOptions(GlobalCompilerOptions):
diff --git a/edb/edgeql/compiler/stmt.py b/edb/edgeql/compiler/stmt.py
--- a/edb/edgeql/compiler/stmt.py
+++ b/edb/edgeql/compiler/stmt.py
@@ -45,6 +45,7 @@
 from edb.schema import objtypes as s_objtypes
 from edb.schema import pointers as s_pointers
 from edb.schema import pseudo as s_pseudo
+from edb.schema import schema as s_schema
 from edb.schema import types as s_types
 from edb.schema import utils as s_utils
 
@@ -462,6 +463,12 @@ def compile_InsertQuery(
                 f'{str(subject_stype.get_shortname(ctx.env.schema))!r}',
                 context=expr.subject.context)
 
+        if _is_forbidden_stdlib_type_for_mod(subject_stype, ctx):
+            raise errors.QueryError(
+                f'cannot insert standard library type '
+                f'{subject_stype.get_displayname(ctx.env.schema)}',
+                context=expr.subject.context)
+
         with ictx.new() as bodyctx:
             # Self-references in INSERT are prohibited.
             pathctx.ban_inserting_path(
@@ -598,6 +605,13 @@ def compile_UpdateQuery(
                 context=expr.subject.context)
 
         mat_stype = schemactx.concretify(subj_type, ctx=ctx)
+
+        if _is_forbidden_stdlib_type_for_mod(mat_stype, ctx):
+            raise errors.QueryError(
+                f'cannot update standard library type '
+                f'{subj_type.get_displayname(ctx.env.schema)}',
+                context=expr.subject.context)
+
         stmt._material_type = typeutils.type_to_typeref(
             ctx.env.schema,
             mat_stype,
@@ -732,6 +746,13 @@ def compile_DeleteQuery(
                 context=expr.subject.context)
 
         mat_stype = schemactx.concretify(subj_type, ctx=ctx)
+
+        if _is_forbidden_stdlib_type_for_mod(mat_stype, ctx):
+            raise errors.QueryError(
+                f'cannot delete standard library type '
+                f'{subj_type.get_displayname(ctx.env.schema)}',
+                context=expr.subject.context)
+
         stmt._material_type = typeutils.type_to_typeref(
             ctx.env.schema,
             mat_stype,
@@ -1524,3 +1545,38 @@ def maybe_add_view(ir: irast.Set, *, ctx: context.ContextLevel) -> irast.Set:
             parser_context=ir.context)
     else:
         return ir
+
+
+def _is_forbidden_stdlib_type_for_mod(
+    t: s_types.Type,
+    ctx: context.ContextLevel
+) -> bool:
+    o = ctx.env.options
+    if o.bootstrap_mode or o.schema_reflection_mode:
+        return False
+
+    schema = ctx.env.schema
+
+    assert isinstance(t, s_objtypes.ObjectType)
+    assert not t.is_view(schema)
+
+    if intersection := t.get_intersection_of(schema):
+        return all((_is_forbidden_stdlib_type_for_mod(it, ctx)
+                    for it in intersection.objects(schema)))
+    elif union := t.get_union_of(schema):
+        return any((_is_forbidden_stdlib_type_for_mod(ut, ctx)
+                    for ut in union.objects(schema)))
+
+    name = t.get_name(schema)
+
+    if (
+        name.get_module_name() == s_name.UnqualName('cfg')
+        and o.in_server_config_op
+    ):
+        # Config ops include various internally generated statements for cfg::
+        return False
+    if name == s_name.QualName('std', 'Object'):
+        # Allow people to mess with the baseclass of user-defined objects to
+        # their hearts' content
+        return False
+    return t.get_name(schema).get_module_name() in s_schema.STD_MODULES
diff --git a/edb/server/compiler/compiler.py b/edb/server/compiler/compiler.py
--- a/edb/server/compiler/compiler.py
+++ b/edb/server/compiler/compiler.py
@@ -1334,6 +1334,7 @@ def _get_compile_options(
         ) or is_explain,
         testmode=_get_config_val(ctx, '__internal_testmode'),
         devmode=_is_dev_instance(ctx),
+        schema_reflection_mode=ctx.schema_reflection_mode
     )
 
 
@@ -1763,6 +1764,7 @@ def _compile_ql_config_op(ctx: CompileContext, ql: qlast.Base):
         schema=schema,
         options=qlcompiler.CompilerOptions(
             modaliases=modaliases,
+            in_server_config_op=True
         ),
     )
 


Test patch:
diff --git a/tests/test_edgeql_delete.py b/tests/test_edgeql_delete.py
--- a/tests/test_edgeql_delete.py
+++ b/tests/test_edgeql_delete.py
@@ -71,6 +71,23 @@ async def test_edgeql_delete_bad_02(self):
                 DELETE std::FreeObject
             ''')
 
+    async def test_edgeql_delete_bad_03(self):
+        async with self.assertRaisesRegexTx(
+            edgedb.QueryError,
+            r'delete standard library type',
+        ):
+            await self.con.execute('''\
+                DELETE schema::Object;
+            ''')
+
+        async with self.assertRaisesRegexTx(
+            edgedb.QueryError,
+            r'delete standard library type',
+        ):
+            await self.con.execute('''\
+                DELETE {default::LinkingType, schema::Object};
+            ''')
+
     async def test_edgeql_delete_simple_01(self):
         # ensure a clean slate, not part of functionality testing
         await self.con.execute(r"""
diff --git a/tests/test_edgeql_insert.py b/tests/test_edgeql_insert.py
--- a/tests/test_edgeql_insert.py
+++ b/tests/test_edgeql_insert.py
@@ -95,6 +95,15 @@ async def test_edgeql_insert_fail_6(self):
                 INSERT Person { name := .name };
             ''')
 
+    async def test_edgeql_insert_fail_7(self):
+        with self.assertRaisesRegex(
+            edgedb.QueryError,
+            r"insert standard library type",
+        ):
+            await self.con.execute('''
+                INSERT schema::Migration { script := 'foo' };
+            ''')
+
     async def test_edgeql_insert_simple_01(self):
         await self.con.execute(r"""
             INSERT InsertTest {
diff --git a/tests/test_edgeql_update.py b/tests/test_edgeql_update.py
--- a/tests/test_edgeql_update.py
+++ b/tests/test_edgeql_update.py
@@ -541,6 +541,15 @@ async def test_edgeql_update_bad_01(self):
                 UPDATE foo SET { bar := 2 };
             ''')
 
+    async def test_edgeql_update_bad_02(self):
+        async with self.assertRaisesRegexTx(
+            edgedb.QueryError,
+            r'update standard library type',
+        ):
+            await self.con.execute('''\
+                UPDATE schema::Migration SET { script := 'foo'};
+            ''')
+
     async def test_edgeql_update_filter_01(self):
         await self.assert_query_result(
             r"""