From 1d6e89200bbef4766674f2a2cb4d654e4a508cb6 Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:52:35 +0800 Subject: [PATCH 01/10] sem strict Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- cmd/tidb-server/main.go | 9 + pkg/config/config.go | 5 + pkg/planner/BUILD.bazel | 1 + pkg/planner/core/planbuilder.go | 7 + pkg/planner/optimize.go | 41 ++- pkg/session/BUILD.bazel | 1 + pkg/session/session.go | 5 + pkg/util/sem/v2/BUILD.bazel | 9 + pkg/util/sem/v2/restricted_hint.go | 42 +++ pkg/util/sem/v2/restricted_hint_test.go | 76 ++++ pkg/util/sem/v2/restricted_statement.go | 368 +++++++++++++++++++ pkg/util/sem/v2/restricted_statement_test.go | 235 ++++++++++++ pkg/util/sem/v2/strict.go | 29 ++ pkg/util/sem/v2/strict_test.go | 31 ++ 14 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 pkg/util/sem/v2/restricted_hint.go create mode 100644 pkg/util/sem/v2/restricted_hint_test.go create mode 100644 pkg/util/sem/v2/restricted_statement.go create mode 100644 pkg/util/sem/v2/restricted_statement_test.go create mode 100644 pkg/util/sem/v2/strict.go create mode 100644 pkg/util/sem/v2/strict_test.go diff --git a/cmd/tidb-server/main.go b/cmd/tidb-server/main.go index ab4d6f58b3120..22c3f3bd84c5a 100644 --- a/cmd/tidb-server/main.go +++ b/cmd/tidb-server/main.go @@ -1175,4 +1175,13 @@ func setupSEM() { sem.Enable() } } + + if cfg.Security.EnableStrictSEM { + if !kerneltype.IsNextGen() { + logutil.BgLogger().Warn("security.enable-strict-sem is set but this is a classic-kernel build; ignoring") + } else { + semv2.EnableStrict() + logutil.BgLogger().Info("strict SEM enabled") + } + } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 1cd59e2448fe9..a77dd05762225 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -688,6 +688,10 @@ type Security struct { EnableSEM bool `toml:"enable-sem" json:"enable-sem"` // SEMConfig represents the path to the SEM configuration file. SEMConfig string `toml:"sem-config" json:"sem-config"` + // EnableStrictSEM turns on the restricted-statement / restricted-hint + // layer of SEM for Next-Gen deployments. Only honored in next-gen builds; + // on classic builds it is ignored with a startup warning. + EnableStrictSEM bool `toml:"enable-strict-sem" json:"enable-strict-sem"` // Allow automatic TLS certificate generation AutoTLS bool `toml:"auto-tls" json:"auto-tls"` MinTLSVersion string `toml:"tls-version" json:"tls-version"` @@ -1167,6 +1171,7 @@ var defaultConf = Config{ SpilledFileEncryptionMethod: SpilledFileEncryptionMethodPlaintext, EnableSEM: false, SEMConfig: "", + EnableStrictSEM: false, AutoTLS: false, RSAKeySize: 4096, AuthTokenJWKS: "", diff --git a/pkg/planner/BUILD.bazel b/pkg/planner/BUILD.bazel index a0adbf6a9ef97..1cd412aeee226 100644 --- a/pkg/planner/BUILD.bazel +++ b/pkg/planner/BUILD.bazel @@ -30,6 +30,7 @@ go_library( "//pkg/util/hint", "//pkg/util/intest", "//pkg/util/logutil", + "//pkg/util/sem/v2:sem", "//pkg/util/topsql", "//pkg/util/tracing", "@com_github_pingcap_errors//:errors", diff --git a/pkg/planner/core/planbuilder.go b/pkg/planner/core/planbuilder.go index af50a5d15fb31..3ac4cd75330c2 100644 --- a/pkg/planner/core/planbuilder.go +++ b/pkg/planner/core/planbuilder.go @@ -6379,6 +6379,13 @@ func GetMaxWriteSpeedFromExpression(opt *AlterDDLJobOpt) (maxWriteSpeed int64, e } func (b *PlanBuilder) checkSEMStmt(stmt ast.Node) error { + // Strict SEM applies before the semv2 rule check with no admin bypass. + if semv2.IsStrictEnabled() { + if err := semv2.IsRestrictedStatement(stmt); err != nil { + return err + } + } + if !semv2.IsEnabled() { return nil } diff --git a/pkg/planner/optimize.go b/pkg/planner/optimize.go index 3e5463b430030..2af813c93b5df 100644 --- a/pkg/planner/optimize.go +++ b/pkg/planner/optimize.go @@ -49,6 +49,7 @@ import ( "github.com/pingcap/tidb/pkg/util/hint" "github.com/pingcap/tidb/pkg/util/intest" "github.com/pingcap/tidb/pkg/util/logutil" + semv2 "github.com/pingcap/tidb/pkg/util/sem/v2" "github.com/pingcap/tidb/pkg/util/topsql" "github.com/pingcap/tidb/pkg/util/tracing" "go.uber.org/zap" @@ -230,6 +231,10 @@ func optimizeNoCache(ctx context.Context, sctx sessionctx.Context, node *resolve sessVars := sctx.GetSessionVars() tableHints := hint.ExtractTableHintsFromStmtNode(node.Node, sessVars.StmtCtx) + tableHints, restrictedHintWarns := filterRestrictedHints(tableHints) + for _, warn := range restrictedHintWarns { + sessVars.StmtCtx.AppendWarning(warn) + } originStmtHints, _, warns := hint.ParseStmtHints(tableHints, setVarHintChecker, hypoIndexChecker(ctx, is), sessVars.CurrentDB, byte(kv.ReplicaReadFollower)) @@ -301,7 +306,11 @@ func optimizeNoCache(ctx context.Context, sctx sessionctx.Context, node *resolve var warns []error if binding != nil && binding.IsBindingEnabled() { hint.BindHint(stmtNode, binding.Hint) - curStmtHints, _, curWarns := hint.ParseStmtHints(binding.Hint.GetStmtHints(), + bindingHints, bindingRestrictedWarns := filterRestrictedHints(binding.Hint.GetStmtHints()) + for _, warn := range bindingRestrictedWarns { + sessVars.StmtCtx.AppendWarning(warn) + } + curStmtHints, _, curWarns := hint.ParseStmtHints(bindingHints, setVarHintChecker, hypoIndexChecker(ctx, is), sessVars.CurrentDB, byte(kv.ReplicaReadFollower)) sessVars.StmtCtx.StmtHints = curStmtHints @@ -820,6 +829,36 @@ func buildLogicalPlan(ctx context.Context, sctx planctx.PlanContext, node *resol return p, nil } +// filterRestrictedHints returns hints with strict-SEM-forbidden entries +// stripped and a warning per strip. When strict is off it returns the input +// slice unchanged. +func filterRestrictedHints(hints []*ast.TableOptimizerHint) ([]*ast.TableOptimizerHint, []error) { + if !semv2.IsStrictEnabled() || len(hints) == 0 { + return hints, nil + } + var ( + filtered []*ast.TableOptimizerHint + warns []error + ) + for i, h := range hints { + if err := semv2.IsRestrictedHint(h.HintName.L); err != nil { + if filtered == nil { + filtered = make([]*ast.TableOptimizerHint, 0, len(hints)-1) + filtered = append(filtered, hints[:i]...) + } + warns = append(warns, err) + continue + } + if filtered != nil { + filtered = append(filtered, h) + } + } + if filtered == nil { + return hints, nil + } + return filtered, warns +} + // setVarHintChecker checks whether the variable name in set_var hint is valid. func setVarHintChecker(varName, hint string) (ok bool, warning error) { sysVar := variable.GetSysVar(varName) diff --git a/pkg/session/BUILD.bazel b/pkg/session/BUILD.bazel index 8ad1c907d87cb..5a9524bdc8440 100644 --- a/pkg/session/BUILD.bazel +++ b/pkg/session/BUILD.bazel @@ -123,6 +123,7 @@ go_library( "//pkg/util/ranger/context", "//pkg/util/redact", "//pkg/util/sem/compat", + "//pkg/util/sem/v2:sem", "//pkg/util/sli", "//pkg/util/sqlescape", "//pkg/util/sqlexec", diff --git a/pkg/session/session.go b/pkg/session/session.go index dcfd740395092..35f6d68bf4e49 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -132,6 +132,7 @@ import ( rangerctx "github.com/pingcap/tidb/pkg/util/ranger/context" "github.com/pingcap/tidb/pkg/util/redact" sem "github.com/pingcap/tidb/pkg/util/sem/compat" + semv2 "github.com/pingcap/tidb/pkg/util/sem/v2" "github.com/pingcap/tidb/pkg/util/sli" "github.com/pingcap/tidb/pkg/util/sqlescape" "github.com/pingcap/tidb/pkg/util/sqlexec" @@ -5517,6 +5518,10 @@ func (s *session) usePipelinedDmlOrWarn(ctx context.Context) bool { if stmtCtx.IsReadOnly { return false } + if semv2.IsStrictEnabled() { + stmtCtx.AppendWarning(errors.New("Pipelined DML is not supported in this deployment. Fallback to standard mode")) + return false + } vars := s.GetSessionVars() if !vars.TxnCtx.EnableMDL { stmtCtx.AppendWarning( diff --git a/pkg/util/sem/v2/BUILD.bazel b/pkg/util/sem/v2/BUILD.bazel index 4c73860736599..430934be1512c 100644 --- a/pkg/util/sem/v2/BUILD.bazel +++ b/pkg/util/sem/v2/BUILD.bazel @@ -4,8 +4,11 @@ go_library( name = "sem", srcs = [ "config.go", + "restricted_hint.go", + "restricted_statement.go", "sem.go", "sql_rule.go", + "strict.go", "testhelper.go", ], importpath = "github.com/pingcap/tidb/pkg/util/sem/v2", @@ -17,9 +20,11 @@ go_library( "//pkg/parser/mysql", "//pkg/sessionctx/vardef", "//pkg/sessionctx/variable", + "//pkg/util/dbterror/plannererrors", "//pkg/util/intest", "//pkg/util/logutil", "@com_github_coreos_go_semver//semver", + "@com_github_pingcap_errors//:errors", "@org_uber_go_zap//:zap", ], ) @@ -29,13 +34,17 @@ go_test( timeout = "short", srcs = [ "config_test.go", + "restricted_hint_test.go", + "restricted_statement_test.go", "sem_test.go", "sql_rule_test.go", + "strict_test.go", ], embed = [":sem"], flaky = True, deps = [ "//pkg/parser", + "//pkg/parser/ast", "//pkg/parser/charset", "//pkg/parser/mysql", "//pkg/sessionctx/vardef", diff --git a/pkg/util/sem/v2/restricted_hint.go b/pkg/util/sem/v2/restricted_hint.go new file mode 100644 index 0000000000000..cbcb49b2985b0 --- /dev/null +++ b/pkg/util/sem/v2/restricted_hint.go @@ -0,0 +1,42 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sem + +import ( + "github.com/pingcap/errors" + "github.com/pingcap/tidb/pkg/sessionctx/vardef" +) + +// IsRestrictedHint returns a non-nil error when a query hint would bypass a +// SEM-masked sysvar. Callers gate on IsStrictEnabled. +func IsRestrictedHint(hintNameLower string) error { + switch hintNameLower { + case "memory_quota": + if IsInvisibleSysVar(vardef.TiDBMemQuotaQuery) || IsReadOnlyVariable(vardef.TiDBMemQuotaQuery) { + return errors.New("MEMORY_QUOTA() is not supported when strict SEM is enabled") + } + case "resource_group": + return errors.New("RESOURCE_GROUP() is not supported when strict SEM is enabled") + case "read_consistent_replica": + if IsInvisibleSysVar(vardef.TiDBReplicaRead) || IsReadOnlyVariable(vardef.TiDBReplicaRead) { + return errors.New("READ_CONSISTENT_REPLICA() is not supported when strict SEM is enabled") + } + case "max_execution_time": + if IsInvisibleSysVar(vardef.MaxExecutionTime) || IsReadOnlyVariable(vardef.MaxExecutionTime) { + return errors.New("MAX_EXECUTION_TIME() is not supported when strict SEM is enabled") + } + } + return nil +} diff --git a/pkg/util/sem/v2/restricted_hint_test.go b/pkg/util/sem/v2/restricted_hint_test.go new file mode 100644 index 0000000000000..6d63ef4427692 --- /dev/null +++ b/pkg/util/sem/v2/restricted_hint_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sem + +import ( + "testing" + + "github.com/pingcap/tidb/pkg/sessionctx/vardef" + "github.com/stretchr/testify/require" +) + +func TestHint_NoSEM(t *testing.T) { + require.NoError(t, IsRestrictedHint("memory_quota")) + require.NoError(t, IsRestrictedHint("read_consistent_replica")) + require.NoError(t, IsRestrictedHint("max_execution_time")) + // resource_group is rejected unconditionally. + require.Error(t, IsRestrictedHint("resource_group")) + require.NoError(t, IsRestrictedHint("use_index")) + require.NoError(t, IsRestrictedHint("")) +} + +func TestHint_WithSEMHidden(t *testing.T) { + t.Cleanup(Disable) + require.NoError(t, EnableBy(&Config{ + TiDBVersion: "v0.0.0", + RestrictedVariables: []VariableRestriction{ + {Name: vardef.TiDBMemQuotaQuery, Hidden: true}, + {Name: vardef.TiDBReplicaRead, Hidden: true}, + {Name: vardef.MaxExecutionTime, Hidden: true}, + }, + })) + + require.Error(t, IsRestrictedHint("memory_quota")) + require.Error(t, IsRestrictedHint("read_consistent_replica")) + require.Error(t, IsRestrictedHint("max_execution_time")) + require.Error(t, IsRestrictedHint("resource_group")) + require.NoError(t, IsRestrictedHint("use_index")) +} + +func TestHint_WithSEMReadonly(t *testing.T) { + t.Cleanup(Disable) + require.NoError(t, EnableBy(&Config{ + TiDBVersion: "v0.0.0", + RestrictedVariables: []VariableRestriction{ + {Name: vardef.TiDBMemQuotaQuery, Readonly: true}, + {Name: vardef.TiDBReplicaRead, Readonly: true}, + {Name: vardef.MaxExecutionTime, Readonly: true}, + }, + })) + + require.Error(t, IsRestrictedHint("memory_quota")) + require.Error(t, IsRestrictedHint("read_consistent_replica")) + require.Error(t, IsRestrictedHint("max_execution_time")) +} + +func TestHint_WithSEMUnrestricted(t *testing.T) { + t.Cleanup(Disable) + require.NoError(t, EnableBy(&Config{TiDBVersion: "v0.0.0"})) + + require.NoError(t, IsRestrictedHint("memory_quota")) + require.NoError(t, IsRestrictedHint("read_consistent_replica")) + require.NoError(t, IsRestrictedHint("max_execution_time")) + require.Error(t, IsRestrictedHint("resource_group")) +} diff --git a/pkg/util/sem/v2/restricted_statement.go b/pkg/util/sem/v2/restricted_statement.go new file mode 100644 index 0000000000000..93b55770a9cb2 --- /dev/null +++ b/pkg/util/sem/v2/restricted_statement.go @@ -0,0 +1,368 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sem + +import ( + "fmt" + + "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/pingcap/tidb/pkg/util/dbterror/plannererrors" +) + +// Literal identifiers protected from DROP/RENAME/role-change operations. +// tidb-cse drives these off a keyspace username policy; upstream has no such +// concept, so we match the raw names at host '%'. +var ( + restrictedUsers = []string{"cloud_admin", "root"} + restrictedRoles = []string{"cloud_admin"} +) + +func isRestrictedUser(userName, hostname string) bool { + if hostname != "%" { + return false + } + for _, u := range restrictedUsers { + if userName == u { + return true + } + } + return false +} + +func isRestrictedRole(userName, hostname string) bool { + if hostname != "%" { + return false + } + for _, r := range restrictedRoles { + if userName == r { + return true + } + } + return false +} + +// IsRestrictedStatement returns a non-nil error when strict SEM forbids stmt. +// It runs the check unconditionally; callers gate on IsStrictEnabled. +func IsRestrictedStatement(stmt ast.Node) error { + switch x := stmt.(type) { + case *ast.DeallocateStmt, + *ast.DeleteStmt, + *ast.ExecuteStmt, + *ast.ExplainStmt, + *ast.ExplainForStmt, + *ast.TraceStmt, + *ast.InsertStmt, + *ast.LockStatsStmt, + *ast.UnlockStatsStmt, + *ast.PlanReplayerStmt, + *ast.PrepareStmt, + *ast.SelectStmt, + *ast.SetOprStmt, + *ast.UpdateStmt, + *ast.DoStmt, + *ast.SetStmt, + *ast.AnalyzeTableStmt, + *ast.CreateBindingStmt, + *ast.DropBindingStmt, + *ast.SetBindingStmt, + *ast.CompactTableStmt: + return nil + case *ast.LoadDataStmt: + return verifyLoadData(x) + case *ast.AdminStmt: + return verifyAdmin(x) + case *ast.LoadStatsStmt: + return verifyLoadStats(x) + case *ast.ShowStmt: + return verifyShow(x) + case *ast.SetConfigStmt: + return verifySetConfig(x) + case *ast.BinlogStmt, *ast.FlushStmt, *ast.UseStmt, *ast.BRIEStmt, + *ast.BeginStmt, *ast.CommitStmt, *ast.SavepointStmt, *ast.ReleaseSavepointStmt, *ast.RollbackStmt, + *ast.CreateUserStmt, *ast.SetPwdStmt, *ast.AlterInstanceStmt, + *ast.GrantStmt, *ast.DropUserStmt, *ast.AlterUserStmt, *ast.RevokeStmt, *ast.KillStmt, *ast.DropStatsStmt, + *ast.GrantRoleStmt, *ast.RevokeRoleStmt, *ast.SetRoleStmt, *ast.SetDefaultRoleStmt, *ast.ShutdownStmt, + *ast.RenameUserStmt, *ast.NonTransactionalDMLStmt, *ast.SetSessionStatesStmt, *ast.SetResourceGroupStmt: + return verifySimple(x) + case ast.DDLNode: + return verifyDDL(x) + case *ast.SplitRegionStmt: + return notSupported("SPLIT REGION") + } + + return nil +} + +func verifyDDL(stmt ast.DDLNode) error { + switch s := stmt.(type) { + case + *ast.CreateDatabaseStmt, + *ast.AlterDatabaseStmt, + *ast.DropDatabaseStmt, + *ast.DropTableStmt, + *ast.DropSequenceStmt, + *ast.RenameTableStmt, + *ast.CreateViewStmt, + *ast.CreateSequenceStmt, + *ast.CreateIndexStmt, + *ast.DropIndexStmt, + *ast.LockTablesStmt, + *ast.UnlockTablesStmt, + *ast.CleanupTableLockStmt, + *ast.RepairTableStmt, + *ast.TruncateTableStmt, + *ast.AlterSequenceStmt, + *ast.RecoverTableStmt, + *ast.FlashBackDatabaseStmt, + *ast.FlashBackTableStmt, + *ast.CreateTableStmt: + // CREATE/ALTER TABLE TTL and table/partition attribute specs are + // covered by the semv2 time_to_live / alter_table_attributes rules. + // EXCHANGE PARTITION is not, so we reject it explicitly below. + return nil + case *ast.AlterTableStmt: + for _, spec := range s.Specs { + if spec.Tp == ast.AlterTableExchangePartition { + return notSupported("ALTER TABLE EXCHANGE PARTITION") + } + } + return nil + case *ast.AlterPlacementPolicyStmt: + return notSupported("ALTER PLACEMENT POLICY") + case *ast.CreatePlacementPolicyStmt: + return notSupported("CREATE PLACEMENT POLICY") + case *ast.DropPlacementPolicyStmt: + return notSupported("DROP PLACEMENT POLICY") + case *ast.DropResourceGroupStmt: + return notSupported("DROP RESOURCE GROUP") + case *ast.CreateResourceGroupStmt: + return notSupported("CREATE RESOURCE GROUP") + case *ast.FlashBackToTimestampStmt: + return notSupported("FLASHBACK CLUSTER") + case *ast.AlterResourceGroupStmt: + return notSupported("ALTER RESOURCE GROUP") + } + return notSupported(fmt.Sprintf("Unsupported DDL %T", stmt)) +} + +func verifySimple(stmt ast.Node) error { + switch s := stmt.(type) { + case + *ast.FlushStmt, + *ast.BeginStmt, + *ast.CommitStmt, + *ast.SavepointStmt, + *ast.ReleaseSavepointStmt, + *ast.RollbackStmt, + *ast.CreateUserStmt, + *ast.AlterUserStmt, + *ast.SetPwdStmt, + *ast.SetSessionStatesStmt, + *ast.KillStmt, + *ast.BinlogStmt, + *ast.DropStatsStmt, + *ast.SetDefaultRoleStmt, + *ast.AdminStmt, + *ast.GrantStmt, + *ast.RevokeStmt, + *ast.NonTransactionalDMLStmt, + *ast.UseStmt: + return nil + case *ast.DropUserStmt: + for _, user := range s.UserList { + if isRestrictedUser(user.Username, user.Hostname) { + return notSupported(fmt.Sprintf("DROP USER %s", user)) + } + } + return nil + case *ast.RenameUserStmt: + for _, userToUser := range s.UserToUsers { + if isRestrictedUser(userToUser.OldUser.Username, userToUser.OldUser.Hostname) { + return notSupported(fmt.Sprintf("RENAME USER %s", userToUser.OldUser)) + } + } + return nil + case *ast.GrantRoleStmt: + for _, role := range s.Roles { + if isRestrictedRole(role.Username, role.Hostname) { + return notSupported(fmt.Sprintf("GRANT ROLE %s", role)) + } + } + return nil + case *ast.RevokeRoleStmt: + for _, role := range s.Roles { + if isRestrictedRole(role.Username, role.Hostname) { + return notSupported(fmt.Sprintf("REVOKE %s", role)) + } + } + return nil + case *ast.SetRoleStmt: + for _, role := range s.RoleList { + if isRestrictedRole(role.Username, role.Hostname) { + return notSupported(fmt.Sprintf("SET ROLE %s", role)) + } + } + return nil + case *ast.AlterInstanceStmt: + return notSupported("ALTER INSTANCE") + case *ast.ShutdownStmt: + return notSupported("SHUTDOWN") + case *ast.BRIEStmt: + return verifyBRIE(s) + case *ast.SetResourceGroupStmt: + return notSupported("SET RESOURCE GROUP") + } + return notSupported(fmt.Sprintf("Unsupported Executor %T", stmt)) +} + +func verifyBRIE(stmt *ast.BRIEStmt) error { + switch stmt.Kind { + case ast.BRIEKindBackup: + return notSupported("BACKUP") + case ast.BRIEKindRestore: + return notSupported("RESTORE") + } + return notSupported("BRIE") +} + +func verifyShow(stmt *ast.ShowStmt) error { + switch stmt.Tp { + case + ast.ShowNone, + ast.ShowEngines, + ast.ShowDatabases, + ast.ShowTables, + ast.ShowTableStatus, + ast.ShowColumns, + ast.ShowWarnings, + ast.ShowCharset, + ast.ShowVariables, + ast.ShowStatus, + ast.ShowCollation, + ast.ShowCreateTable, + ast.ShowCreateView, + ast.ShowCreateUser, + ast.ShowCreateSequence, + ast.ShowGrants, + ast.ShowTriggers, + ast.ShowProcedureStatus, + ast.ShowFunctionStatus, + ast.ShowIndex, + ast.ShowProcessList, + ast.ShowCreateDatabase, + ast.ShowEvents, + ast.ShowStatsExtended, + ast.ShowStatsMeta, + ast.ShowStatsHistograms, + ast.ShowStatsTopN, + ast.ShowStatsBuckets, + ast.ShowStatsHealthy, + ast.ShowStatsLocked, + ast.ShowHistogramsInFlight, + ast.ShowColumnStatsUsage, + ast.ShowProfile, + ast.ShowProfiles, + ast.ShowMasterStatus, + ast.ShowPrivileges, + ast.ShowErrors, + ast.ShowBindings, + ast.ShowBindingCacheStatus, + ast.ShowOpenTables, + ast.ShowAnalyzeStatus, + ast.ShowBuiltins, + ast.ShowTableNextRowId, + ast.ShowImports, + ast.ShowImportJobs, + ast.ShowCreateImport, + ast.ShowSessionStates, + ast.ShowConfig, + ast.ShowPlugins: + return nil + case ast.ShowCreateResourceGroup: + return notSupported("SHOW CREATE RESOURCE GROUP") + case ast.ShowCreatePlacementPolicy: + return notSupported("SHOW CREATE PLACEMENT POLICY") + case ast.ShowBackups: + return notSupported("SHOW BACKUPS") + case ast.ShowRestores: + return notSupported("SHOW RESTORES") + case ast.ShowPlacement: + return notSupported("SHOW PLACEMENT POLICY") + case ast.ShowPlacementForDatabase: + return notSupported("SHOW PLACEMENT FOR DATABASE") + case ast.ShowPlacementForTable: + return notSupported("SHOW PLACEMENT FOR TABLE") + case ast.ShowPlacementForPartition: + return notSupported("SHOW PLACEMENT FOR PARTITION") + case ast.ShowPlacementLabels: + return notSupported("SHOW PLACEMENT LABELS") + case ast.ShowRegions: + return notSupported("SHOW TABLE REGIONS") + } + return notSupported("Unsupported SHOW type") +} + +func verifyLoadStats(_ *ast.LoadStatsStmt) error { + return notSupported("LOAD STATS") +} + +func verifyLoadData(stmt *ast.LoadDataStmt) error { + if stmt.FileLocRef == ast.FileLocClient { + return nil + } + return notSupported("LOAD DATA INFILE") +} + +func verifyAdmin(stmt *ast.AdminStmt) error { + switch stmt.Tp { + case + ast.AdminShowDDL, + ast.AdminCheckTable, + ast.AdminShowDDLJobs, + ast.AdminCancelDDLJobs, + ast.AdminCheckIndex, + ast.AdminRecoverIndex, + ast.AdminCleanupIndex, + ast.AdminCheckIndexRange, + ast.AdminShowDDLJobQueries, + ast.AdminShowDDLJobQueriesWithRange, + ast.AdminChecksumTable, + ast.AdminShowNextRowID, + ast.AdminReloadExprPushdownBlacklist, + ast.AdminReloadOptRuleBlacklist, + ast.AdminFlushBindings, + ast.AdminCaptureBindings, + ast.AdminEvolveBindings, + ast.AdminReloadBindings, + ast.AdminReloadStatistics, + ast.AdminFlushPlanCache: + return nil + case ast.AdminPluginDisable: + return notSupported("ADMIN PLUGIN DISABLE") + case ast.AdminPluginEnable: + return notSupported("ADMIN PLUGIN ENABLE") + case ast.AdminShowSlow: + return notSupported("ADMIN SHOW SLOW") + } + return notSupported(fmt.Sprintf("Unsupported statement: %T", stmt)) +} + +func verifySetConfig(_ *ast.SetConfigStmt) error { + return notSupported("SET CONFIG") +} + +func notSupported(feature string) error { + return plannererrors.ErrNotSupportedWithSem.GenWithStackByArgs(feature) +} diff --git a/pkg/util/sem/v2/restricted_statement_test.go b/pkg/util/sem/v2/restricted_statement_test.go new file mode 100644 index 0000000000000..1bea58e5155ff --- /dev/null +++ b/pkg/util/sem/v2/restricted_statement_test.go @@ -0,0 +1,235 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sem + +import ( + "strings" + "testing" + + "github.com/pingcap/tidb/pkg/parser" + "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/stretchr/testify/require" +) + +func parseOne(t *testing.T, sql string) ast.StmtNode { + t.Helper() + p := parser.New() + stmt, err := p.ParseOneStmt(sql, "", "") + require.NoError(t, err, "failed to parse: %s", sql) + return stmt +} + +// mustPass asserts the statement is accepted by strict SEM. +func mustPass(t *testing.T, sql string) { + t.Helper() + stmt := parseOne(t, sql) + require.NoError(t, IsRestrictedStatement(stmt), "expected statement to pass strict check: %s", sql) +} + +// mustReject asserts the statement is rejected; if wantSubstr is non-empty, +// also asserts the error message contains it. +func mustReject(t *testing.T, sql, wantSubstr string) { + t.Helper() + stmt := parseOne(t, sql) + err := IsRestrictedStatement(stmt) + require.Error(t, err, "expected statement to be rejected: %s", sql) + if wantSubstr != "" { + require.True(t, strings.Contains(err.Error(), wantSubstr), + "error %q should mention %q for sql: %s", err.Error(), wantSubstr, sql) + } +} + +func TestStatement_AllowedDML(t *testing.T) { + cases := []string{ + "SELECT 1", + "SELECT * FROM t WHERE c = 1", + "INSERT INTO t VALUES (1)", + "UPDATE t SET c = 1 WHERE id = 1", + "DELETE FROM t WHERE id = 1", + "PREPARE stmt FROM 'SELECT 1'", + "EXECUTE stmt", + "DEALLOCATE PREPARE stmt", + "EXPLAIN SELECT 1", + "TRACE SELECT 1", + "ANALYZE TABLE t", + "CREATE GLOBAL BINDING FOR SELECT 1 USING SELECT 1", + "DROP GLOBAL BINDING FOR SELECT 1", + "SELECT 1 UNION SELECT 2", + "DO SLEEP(0)", + } + for _, sql := range cases { + mustPass(t, sql) + } +} + +func TestStatement_AllowedDDL(t *testing.T) { + cases := []string{ + "CREATE DATABASE foo", + "ALTER DATABASE foo CHARACTER SET utf8mb4", + "DROP DATABASE foo", + "CREATE TABLE t (a int)", + "ALTER TABLE t ADD COLUMN b INT", + "DROP TABLE t", + "RENAME TABLE t TO u", + "TRUNCATE TABLE t", + "CREATE INDEX idx ON t (a)", + "DROP INDEX idx ON t", + "CREATE VIEW v AS SELECT 1", + "CREATE SEQUENCE s", + "DROP SEQUENCE s", + "LOCK TABLES t READ", + "UNLOCK TABLES", + "FLASHBACK TABLE t", + } + for _, sql := range cases { + mustPass(t, sql) + } +} + +func TestStatement_RejectedDDL(t *testing.T) { + cases := []struct { + sql, want string + }{ + {"CREATE PLACEMENT POLICY p FOLLOWERS=3", "CREATE PLACEMENT POLICY"}, + {"ALTER PLACEMENT POLICY p FOLLOWERS=3", "ALTER PLACEMENT POLICY"}, + {"DROP PLACEMENT POLICY p", "DROP PLACEMENT POLICY"}, + {"CREATE RESOURCE GROUP rg RU_PER_SEC=1000", "CREATE RESOURCE GROUP"}, + {"ALTER RESOURCE GROUP rg RU_PER_SEC=2000", "ALTER RESOURCE GROUP"}, + {"DROP RESOURCE GROUP rg", "DROP RESOURCE GROUP"}, + {"FLASHBACK CLUSTER TO TIMESTAMP '2025-01-01 00:00:00'", "FLASHBACK CLUSTER"}, + {"ALTER TABLE t EXCHANGE PARTITION p WITH TABLE u", "ALTER TABLE EXCHANGE PARTITION"}, + } + for _, c := range cases { + mustReject(t, c.sql, c.want) + } +} + +func TestStatement_RejectedSimple(t *testing.T) { + cases := []struct { + sql, want string + }{ + {"ALTER INSTANCE RELOAD TLS", "ALTER INSTANCE"}, + {"SHUTDOWN", "SHUTDOWN"}, + {"BACKUP DATABASE * TO 's3://x/y'", "BACKUP"}, + {"RESTORE DATABASE * FROM 's3://x/y'", "RESTORE"}, + {"SET RESOURCE GROUP rg", "SET RESOURCE GROUP"}, + } + for _, c := range cases { + mustReject(t, c.sql, c.want) + } +} + +func TestStatement_RestrictedUserOperations(t *testing.T) { + mustReject(t, `DROP USER 'cloud_admin'@'%'`, "DROP USER") + mustReject(t, `DROP USER 'root'@'%'`, "DROP USER") + mustPass(t, `DROP USER 'joe'@'%'`) + mustPass(t, `DROP USER 'root'@'localhost'`) + + mustReject(t, `RENAME USER 'cloud_admin'@'%' TO 'evil'@'%'`, "RENAME USER") + mustPass(t, `RENAME USER 'joe'@'%' TO 'jane'@'%'`) + + mustReject(t, `GRANT 'cloud_admin'@'%' TO 'alice'@'%'`, "GRANT ROLE") + mustPass(t, `GRANT 'role1'@'%' TO 'alice'@'%'`) + + mustReject(t, `REVOKE 'cloud_admin'@'%' FROM 'alice'@'%'`, "REVOKE") + mustPass(t, `REVOKE 'role1'@'%' FROM 'alice'@'%'`) + + mustReject(t, `SET ROLE 'cloud_admin'@'%'`, "SET ROLE") + mustPass(t, `SET ROLE 'role1'@'%'`) + + mustPass(t, `CREATE USER 'x'@'%' IDENTIFIED BY 'pw'`) + mustPass(t, `ALTER USER 'x'@'%' IDENTIFIED BY 'pw2'`) + mustPass(t, `SET PASSWORD FOR 'x'@'%' = 'pw3'`) +} + +func TestStatement_LoadData(t *testing.T) { + mustPass(t, `LOAD DATA LOCAL INFILE '/tmp/x.csv' INTO TABLE t`) + mustReject(t, `LOAD DATA INFILE 's3://bucket/x.csv' INTO TABLE t`, "LOAD DATA INFILE") + mustReject(t, `LOAD DATA INFILE '/etc/passwd' INTO TABLE t`, "LOAD DATA INFILE") +} + +func TestStatement_Show(t *testing.T) { + allowed := []string{ + "SHOW DATABASES", + "SHOW TABLES", + "SHOW ENGINES", + "SHOW GRANTS", + "SHOW VARIABLES", + "SHOW CREATE TABLE t", + "SHOW ANALYZE STATUS", + "SHOW CONFIG", + "SHOW PLUGINS", + } + for _, sql := range allowed { + mustPass(t, sql) + } + rejected := []struct{ sql, want string }{ + {"SHOW CREATE RESOURCE GROUP rg", "SHOW CREATE RESOURCE GROUP"}, + {"SHOW CREATE PLACEMENT POLICY p", "SHOW CREATE PLACEMENT POLICY"}, + {"SHOW BACKUPS", "SHOW BACKUPS"}, + {"SHOW RESTORES", "SHOW RESTORES"}, + {"SHOW PLACEMENT", "SHOW PLACEMENT POLICY"}, + {"SHOW TABLE t REGIONS", "SHOW TABLE REGIONS"}, + } + for _, c := range rejected { + mustReject(t, c.sql, c.want) + } +} + +func TestStatement_Admin(t *testing.T) { + allowed := []string{ + "ADMIN SHOW DDL", + "ADMIN SHOW DDL JOBS", + "ADMIN CANCEL DDL JOBS 1", + "ADMIN CHECK TABLE t", + "ADMIN CHECKSUM TABLE t", + "ADMIN RELOAD EXPR_PUSHDOWN_BLACKLIST", + "ADMIN FLUSH BINDINGS", + "ADMIN RELOAD STATISTICS", + } + for _, sql := range allowed { + mustPass(t, sql) + } + rejected := []struct{ sql, want string }{ + {"ADMIN PLUGINS ENABLE foo", "ADMIN PLUGIN ENABLE"}, + {"ADMIN PLUGINS DISABLE foo", "ADMIN PLUGIN DISABLE"}, + {"ADMIN SHOW SLOW RECENT 10", "ADMIN SHOW SLOW"}, + } + for _, c := range rejected { + mustReject(t, c.sql, c.want) + } +} + +func TestStatement_MiscRejects(t *testing.T) { + mustReject(t, "LOAD STATS '/tmp/stats'", "LOAD STATS") + mustReject(t, "SPLIT TABLE t BETWEEN (0) AND (1000) REGIONS 10", "SPLIT REGION") + mustReject(t, "SET CONFIG tidb `log.level` = 'debug'", "SET CONFIG") +} + +func TestStatement_Transactional(t *testing.T) { + cases := []string{ + "BEGIN", + "START TRANSACTION", + "COMMIT", + "ROLLBACK", + "SAVEPOINT s1", + "ROLLBACK TO SAVEPOINT s1", + "RELEASE SAVEPOINT s1", + "USE test", + } + for _, sql := range cases { + mustPass(t, sql) + } +} diff --git a/pkg/util/sem/v2/strict.go b/pkg/util/sem/v2/strict.go new file mode 100644 index 0000000000000..08f1f8d7c3c44 --- /dev/null +++ b/pkg/util/sem/v2/strict.go @@ -0,0 +1,29 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sem + +import "sync/atomic" + +var strictEnabled atomic.Bool + +// EnableStrict turns on the strict-SEM allow-list (restricted statements and +// hints) used in next-gen deployments. +func EnableStrict() { strictEnabled.Store(true) } + +// DisableStrict is test-only. +func DisableStrict() { strictEnabled.Store(false) } + +// IsStrictEnabled reports whether strict SEM is active. +func IsStrictEnabled() bool { return strictEnabled.Load() } diff --git a/pkg/util/sem/v2/strict_test.go b/pkg/util/sem/v2/strict_test.go new file mode 100644 index 0000000000000..cde5d993e996a --- /dev/null +++ b/pkg/util/sem/v2/strict_test.go @@ -0,0 +1,31 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sem + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStrictEnableDisable(t *testing.T) { + t.Cleanup(DisableStrict) + + require.False(t, IsStrictEnabled(), "default must be off") + EnableStrict() + require.True(t, IsStrictEnabled()) + DisableStrict() + require.False(t, IsStrictEnabled()) +} From 315ee14cc6b89e83232c588c877ed973c1a245bb Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:57:42 +0800 Subject: [PATCH 02/10] update setup Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- cmd/tidb-server/main.go | 21 +++++++++++++-------- pkg/config/config.go | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/cmd/tidb-server/main.go b/cmd/tidb-server/main.go index 22c3f3bd84c5a..d59887df0d47a 100644 --- a/cmd/tidb-server/main.go +++ b/cmd/tidb-server/main.go @@ -1165,15 +1165,20 @@ func enablePyroscope() { func setupSEM() { cfg := config.GetGlobalConfig() - if cfg.Security.EnableSEM { - if cfg.Security.SEMConfig != "" { - err := semv2.Enable(cfg.Security.SEMConfig) - if err != nil { - logutil.BgLogger().Fatal("failed to enable SEM", zap.Error(err)) - } - } else { - sem.Enable() + if !cfg.Security.EnableSEM { + if cfg.Security.EnableStrictSEM { + logutil.BgLogger().Warn("security.enable-strict-sem requires security.enable-sem=true; ignoring") } + return + } + + if cfg.Security.SEMConfig != "" { + err := semv2.Enable(cfg.Security.SEMConfig) + if err != nil { + logutil.BgLogger().Fatal("failed to enable SEM", zap.Error(err)) + } + } else { + sem.Enable() } if cfg.Security.EnableStrictSEM { diff --git a/pkg/config/config.go b/pkg/config/config.go index a77dd05762225..926437fdd4f72 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -689,8 +689,8 @@ type Security struct { // SEMConfig represents the path to the SEM configuration file. SEMConfig string `toml:"sem-config" json:"sem-config"` // EnableStrictSEM turns on the restricted-statement / restricted-hint - // layer of SEM for Next-Gen deployments. Only honored in next-gen builds; - // on classic builds it is ignored with a startup warning. + // layer of SEM for Next-Gen deployments. Requires EnableSEM=true and a + // next-gen build; otherwise ignored with a startup warning. EnableStrictSEM bool `toml:"enable-strict-sem" json:"enable-strict-sem"` // Allow automatic TLS certificate generation AutoTLS bool `toml:"auto-tls" json:"auto-tls"` From 6f54e47d7f68ccc58fe3d63d7cf3b34c5475712f Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:01:43 +0800 Subject: [PATCH 03/10] cleanup Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- pkg/util/sem/v2/BUILD.bazel | 1 - pkg/util/sem/v2/strict_test.go | 31 ------------------------------- 2 files changed, 32 deletions(-) delete mode 100644 pkg/util/sem/v2/strict_test.go diff --git a/pkg/util/sem/v2/BUILD.bazel b/pkg/util/sem/v2/BUILD.bazel index 430934be1512c..a54c69803ac12 100644 --- a/pkg/util/sem/v2/BUILD.bazel +++ b/pkg/util/sem/v2/BUILD.bazel @@ -38,7 +38,6 @@ go_test( "restricted_statement_test.go", "sem_test.go", "sql_rule_test.go", - "strict_test.go", ], embed = [":sem"], flaky = True, diff --git a/pkg/util/sem/v2/strict_test.go b/pkg/util/sem/v2/strict_test.go deleted file mode 100644 index cde5d993e996a..0000000000000 --- a/pkg/util/sem/v2/strict_test.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2026 PingCAP, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sem - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestStrictEnableDisable(t *testing.T) { - t.Cleanup(DisableStrict) - - require.False(t, IsStrictEnabled(), "default must be off") - EnableStrict() - require.True(t, IsStrictEnabled()) - DisableStrict() - require.False(t, IsStrictEnabled()) -} From ef9fae6ff1c19b3d306220d6c3c24e672a7a018c Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:15:46 +0800 Subject: [PATCH 04/10] optimize Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- pkg/planner/optimize.go | 19 +++-------- pkg/util/sem/v2/restricted_statement.go | 33 +++----------------- pkg/util/sem/v2/restricted_statement_test.go | 4 +-- 3 files changed, 10 insertions(+), 46 deletions(-) diff --git a/pkg/planner/optimize.go b/pkg/planner/optimize.go index 2af813c93b5df..62bf962a4b7b5 100644 --- a/pkg/planner/optimize.go +++ b/pkg/planner/optimize.go @@ -836,25 +836,14 @@ func filterRestrictedHints(hints []*ast.TableOptimizerHint) ([]*ast.TableOptimiz if !semv2.IsStrictEnabled() || len(hints) == 0 { return hints, nil } - var ( - filtered []*ast.TableOptimizerHint - warns []error - ) - for i, h := range hints { + filtered := make([]*ast.TableOptimizerHint, 0, len(hints)) + var warns []error + for _, h := range hints { if err := semv2.IsRestrictedHint(h.HintName.L); err != nil { - if filtered == nil { - filtered = make([]*ast.TableOptimizerHint, 0, len(hints)-1) - filtered = append(filtered, hints[:i]...) - } warns = append(warns, err) continue } - if filtered != nil { - filtered = append(filtered, h) - } - } - if filtered == nil { - return hints, nil + filtered = append(filtered, h) } return filtered, warns } diff --git a/pkg/util/sem/v2/restricted_statement.go b/pkg/util/sem/v2/restricted_statement.go index 93b55770a9cb2..81ac0ad095d67 100644 --- a/pkg/util/sem/v2/restricted_statement.go +++ b/pkg/util/sem/v2/restricted_statement.go @@ -16,6 +16,7 @@ package sem import ( "fmt" + "slices" "github.com/pingcap/tidb/pkg/parser/ast" "github.com/pingcap/tidb/pkg/util/dbterror/plannererrors" @@ -30,27 +31,11 @@ var ( ) func isRestrictedUser(userName, hostname string) bool { - if hostname != "%" { - return false - } - for _, u := range restrictedUsers { - if userName == u { - return true - } - } - return false + return hostname == "%" && slices.Contains(restrictedUsers, userName) } func isRestrictedRole(userName, hostname string) bool { - if hostname != "%" { - return false - } - for _, r := range restrictedRoles { - if userName == r { - return true - } - } - return false + return hostname == "%" && slices.Contains(restrictedRoles, userName) } // IsRestrictedStatement returns a non-nil error when strict SEM forbids stmt. @@ -84,11 +69,11 @@ func IsRestrictedStatement(stmt ast.Node) error { case *ast.AdminStmt: return verifyAdmin(x) case *ast.LoadStatsStmt: - return verifyLoadStats(x) + return notSupported("LOAD STATS") case *ast.ShowStmt: return verifyShow(x) case *ast.SetConfigStmt: - return verifySetConfig(x) + return notSupported("SET CONFIG") case *ast.BinlogStmt, *ast.FlushStmt, *ast.UseStmt, *ast.BRIEStmt, *ast.BeginStmt, *ast.CommitStmt, *ast.SavepointStmt, *ast.ReleaseSavepointStmt, *ast.RollbackStmt, *ast.CreateUserStmt, *ast.SetPwdStmt, *ast.AlterInstanceStmt, @@ -314,10 +299,6 @@ func verifyShow(stmt *ast.ShowStmt) error { return notSupported("Unsupported SHOW type") } -func verifyLoadStats(_ *ast.LoadStatsStmt) error { - return notSupported("LOAD STATS") -} - func verifyLoadData(stmt *ast.LoadDataStmt) error { if stmt.FileLocRef == ast.FileLocClient { return nil @@ -359,10 +340,6 @@ func verifyAdmin(stmt *ast.AdminStmt) error { return notSupported(fmt.Sprintf("Unsupported statement: %T", stmt)) } -func verifySetConfig(_ *ast.SetConfigStmt) error { - return notSupported("SET CONFIG") -} - func notSupported(feature string) error { return plannererrors.ErrNotSupportedWithSem.GenWithStackByArgs(feature) } diff --git a/pkg/util/sem/v2/restricted_statement_test.go b/pkg/util/sem/v2/restricted_statement_test.go index 1bea58e5155ff..8718f87b48806 100644 --- a/pkg/util/sem/v2/restricted_statement_test.go +++ b/pkg/util/sem/v2/restricted_statement_test.go @@ -15,7 +15,6 @@ package sem import ( - "strings" "testing" "github.com/pingcap/tidb/pkg/parser" @@ -46,8 +45,7 @@ func mustReject(t *testing.T, sql, wantSubstr string) { err := IsRestrictedStatement(stmt) require.Error(t, err, "expected statement to be rejected: %s", sql) if wantSubstr != "" { - require.True(t, strings.Contains(err.Error(), wantSubstr), - "error %q should mention %q for sql: %s", err.Error(), wantSubstr, sql) + require.Contains(t, err.Error(), wantSubstr, "sql: %s", sql) } } From 077a55440377ffa64723501212795dfca3f3826a Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Wed, 6 May 2026 17:54:43 +0800 Subject: [PATCH 05/10] rename config name Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- cmd/tidb-server/main.go | 15 ++++++--------- pkg/config/config.go | 12 +++++++----- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/cmd/tidb-server/main.go b/cmd/tidb-server/main.go index d59887df0d47a..fa2e1a967f65c 100644 --- a/cmd/tidb-server/main.go +++ b/cmd/tidb-server/main.go @@ -1166,8 +1166,8 @@ func setupSEM() { cfg := config.GetGlobalConfig() if !cfg.Security.EnableSEM { - if cfg.Security.EnableStrictSEM { - logutil.BgLogger().Warn("security.enable-strict-sem requires security.enable-sem=true; ignoring") + if cfg.StarterEssential { + logutil.BgLogger().Warn("starter-essential requires security.enable-sem=true to enable strict SEM; ignoring") } return } @@ -1181,12 +1181,9 @@ func setupSEM() { sem.Enable() } - if cfg.Security.EnableStrictSEM { - if !kerneltype.IsNextGen() { - logutil.BgLogger().Warn("security.enable-strict-sem is set but this is a classic-kernel build; ignoring") - } else { - semv2.EnableStrict() - logutil.BgLogger().Info("strict SEM enabled") - } + // Next-gen is enforced by Config.Valid. + if cfg.StarterEssential { + semv2.EnableStrict() + logutil.BgLogger().Info("starter/essential SEM enabled") } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 926437fdd4f72..988d733f9816c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -199,6 +199,9 @@ type Config struct { VersionComment string `toml:"version-comment" json:"version-comment"` TiDBEdition string `toml:"tidb-edition" json:"tidb-edition"` TiDBReleaseVersion string `toml:"tidb-release-version" json:"tidb-release-version"` + // StarterEssential gates behavior specific to Starter and Essential tier + // deployments (for example the strict-SEM allow-list). Next-gen only. + StarterEssential bool `toml:"starter-essential" json:"starter-essential"` KeyspaceName string `toml:"keyspace-name" json:"keyspace-name"` TiKVWorkerURL string `toml:"tikv-worker-url" json:"tikv-worker-url"` Log Log `toml:"log" json:"log"` @@ -688,10 +691,6 @@ type Security struct { EnableSEM bool `toml:"enable-sem" json:"enable-sem"` // SEMConfig represents the path to the SEM configuration file. SEMConfig string `toml:"sem-config" json:"sem-config"` - // EnableStrictSEM turns on the restricted-statement / restricted-hint - // layer of SEM for Next-Gen deployments. Requires EnableSEM=true and a - // next-gen build; otherwise ignored with a startup warning. - EnableStrictSEM bool `toml:"enable-strict-sem" json:"enable-strict-sem"` // Allow automatic TLS certificate generation AutoTLS bool `toml:"auto-tls" json:"auto-tls"` MinTLSVersion string `toml:"tls-version" json:"tls-version"` @@ -1042,6 +1041,7 @@ var defaultConf = Config{ TiDBEdition: "", VersionComment: "", TiDBReleaseVersion: "", + StarterEssential: false, RUV2: DefaultRUV2Config(), Log: Log{ Level: "info", @@ -1171,7 +1171,6 @@ var defaultConf = Config{ SpilledFileEncryptionMethod: SpilledFileEncryptionMethodPlaintext, EnableSEM: false, SEMConfig: "", - EnableStrictSEM: false, AutoTLS: false, RSAKeySize: 4096, AuthTokenJWKS: "", @@ -1446,6 +1445,9 @@ func (c *Config) Valid() error { if c.Security.SkipGrantTable && !hasRootPrivilege() { return fmt.Errorf("TiDB run with skip-grant-table need root privilege") } + if c.StarterEssential && !kerneltype.IsNextGen() { + return fmt.Errorf("starter-essential is only supported on next-gen kernel builds") + } if !c.Store.Valid() { return fmt.Errorf("invalid store=%s, valid storages=%v", c.Store, StoreTypeList()) } From af525996c6972969b107b297bdfd5053fc1e96dd Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Wed, 6 May 2026 18:07:07 +0800 Subject: [PATCH 06/10] *: fix gofmt alignment after StarterEssential field Inserting the StarterEssential field with a leading comment split the Config struct's tag-alignment block, so gofmt wants narrower padding for the post-comment fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/config/config.go | 52 ++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 988d733f9816c..49495d5153ffb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -193,34 +193,34 @@ type Config struct { TempStoragePath string `toml:"tmp-storage-path" json:"tmp-storage-path"` // TempStorageQuota describe the temporary storage Quota during query exector when TiDBEnableTmpStorageOnOOM is enabled // If the quota exceed the capacity of the TempStoragePath, the tidb-server would exit with fatal error - TempStorageQuota int64 `toml:"tmp-storage-quota" json:"tmp-storage-quota"` // Bytes - TxnLocalLatches tikvcfg.TxnLocalLatches `toml:"-" json:"-"` - ServerVersion string `toml:"server-version" json:"server-version"` - VersionComment string `toml:"version-comment" json:"version-comment"` - TiDBEdition string `toml:"tidb-edition" json:"tidb-edition"` - TiDBReleaseVersion string `toml:"tidb-release-version" json:"tidb-release-version"` + TempStorageQuota int64 `toml:"tmp-storage-quota" json:"tmp-storage-quota"` // Bytes + TxnLocalLatches tikvcfg.TxnLocalLatches `toml:"-" json:"-"` + ServerVersion string `toml:"server-version" json:"server-version"` + VersionComment string `toml:"version-comment" json:"version-comment"` + TiDBEdition string `toml:"tidb-edition" json:"tidb-edition"` + TiDBReleaseVersion string `toml:"tidb-release-version" json:"tidb-release-version"` // StarterEssential gates behavior specific to Starter and Essential tier // deployments (for example the strict-SEM allow-list). Next-gen only. - StarterEssential bool `toml:"starter-essential" json:"starter-essential"` - KeyspaceName string `toml:"keyspace-name" json:"keyspace-name"` - TiKVWorkerURL string `toml:"tikv-worker-url" json:"tikv-worker-url"` - Log Log `toml:"log" json:"log"` - Instance Instance `toml:"instance" json:"instance"` - Security Security `toml:"security" json:"security"` - Status Status `toml:"status" json:"status"` - Performance Performance `toml:"performance" json:"performance"` - PreparedPlanCache PreparedPlanCache `toml:"prepared-plan-cache" json:"prepared-plan-cache"` - OpenTracing OpenTracing `toml:"opentracing" json:"opentracing"` - ProxyProtocol ProxyProtocol `toml:"proxy-protocol" json:"proxy-protocol"` - PDClient tikvcfg.PDClient `toml:"pd-client" json:"pd-client"` - TiKVClient tikvcfg.TiKVClient `toml:"tikv-client" json:"tikv-client"` - RUV2 RUV2Config `toml:"ru-v2" json:"ru-v2"` - CompatibleKillQuery bool `toml:"compatible-kill-query" json:"compatible-kill-query"` - PessimisticTxn PessimisticTxn `toml:"pessimistic-txn" json:"pessimistic-txn"` - MaxIndexLength int `toml:"max-index-length" json:"max-index-length"` - IndexLimit int `toml:"index-limit" json:"index-limit"` - TableColumnCountLimit uint32 `toml:"table-column-count-limit" json:"table-column-count-limit"` - GracefulWaitBeforeShutdown int `toml:"graceful-wait-before-shutdown" json:"graceful-wait-before-shutdown"` + StarterEssential bool `toml:"starter-essential" json:"starter-essential"` + KeyspaceName string `toml:"keyspace-name" json:"keyspace-name"` + TiKVWorkerURL string `toml:"tikv-worker-url" json:"tikv-worker-url"` + Log Log `toml:"log" json:"log"` + Instance Instance `toml:"instance" json:"instance"` + Security Security `toml:"security" json:"security"` + Status Status `toml:"status" json:"status"` + Performance Performance `toml:"performance" json:"performance"` + PreparedPlanCache PreparedPlanCache `toml:"prepared-plan-cache" json:"prepared-plan-cache"` + OpenTracing OpenTracing `toml:"opentracing" json:"opentracing"` + ProxyProtocol ProxyProtocol `toml:"proxy-protocol" json:"proxy-protocol"` + PDClient tikvcfg.PDClient `toml:"pd-client" json:"pd-client"` + TiKVClient tikvcfg.TiKVClient `toml:"tikv-client" json:"tikv-client"` + RUV2 RUV2Config `toml:"ru-v2" json:"ru-v2"` + CompatibleKillQuery bool `toml:"compatible-kill-query" json:"compatible-kill-query"` + PessimisticTxn PessimisticTxn `toml:"pessimistic-txn" json:"pessimistic-txn"` + MaxIndexLength int `toml:"max-index-length" json:"max-index-length"` + IndexLimit int `toml:"index-limit" json:"index-limit"` + TableColumnCountLimit uint32 `toml:"table-column-count-limit" json:"table-column-count-limit"` + GracefulWaitBeforeShutdown int `toml:"graceful-wait-before-shutdown" json:"graceful-wait-before-shutdown"` // AlterPrimaryKey is used to control alter primary key feature. AlterPrimaryKey bool `toml:"alter-primary-key" json:"alter-primary-key"` // TreatOldVersionUTF8AsUTF8MB4 is use to treat old version table/column UTF8 charset as UTF8MB4. This is for compatibility. From 87dcf68971d5825c39bf069d3993bf840e94ea49 Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Wed, 6 May 2026 18:32:28 +0800 Subject: [PATCH 07/10] *: gate IsRestrictedHint internally, document resource_group, add strict-SEM pipelined-DML test - IsRestrictedHint now self-gates on IsStrictEnabled, matching the documented contract; the four hint tests pivot to enabling strict before each scenario. - Doc-comment now spells out that resource_group is rejected outright under starter/essential because resource group management is unavailable in those deployments. - TestPipelinedDMLNegative now covers the strict-SEM fallback warning. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/util/sem/v2/restricted_hint.go | 10 ++++++++-- pkg/util/sem/v2/restricted_hint_test.go | 20 +++++++++++++------ .../realtikvtest/pipelineddmltest/BUILD.bazel | 1 + .../pipelineddmltest/pipelineddml_test.go | 7 +++++++ 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/pkg/util/sem/v2/restricted_hint.go b/pkg/util/sem/v2/restricted_hint.go index cbcb49b2985b0..a27f38e1af661 100644 --- a/pkg/util/sem/v2/restricted_hint.go +++ b/pkg/util/sem/v2/restricted_hint.go @@ -19,9 +19,15 @@ import ( "github.com/pingcap/tidb/pkg/sessionctx/vardef" ) -// IsRestrictedHint returns a non-nil error when a query hint would bypass a -// SEM-masked sysvar. Callers gate on IsStrictEnabled. +// IsRestrictedHint returns a non-nil error when strict SEM forbids the hint. +// memory_quota / read_consistent_replica / max_execution_time piggyback on +// sysvar visibility (a hint is rejected only when the corresponding sysvar +// is itself hidden or read-only). resource_group is rejected outright because +// resource group management is unavailable to starter/essential deployments. func IsRestrictedHint(hintNameLower string) error { + if !IsStrictEnabled() { + return nil + } switch hintNameLower { case "memory_quota": if IsInvisibleSysVar(vardef.TiDBMemQuotaQuery) || IsReadOnlyVariable(vardef.TiDBMemQuotaQuery) { diff --git a/pkg/util/sem/v2/restricted_hint_test.go b/pkg/util/sem/v2/restricted_hint_test.go index 6d63ef4427692..e0320f84e1afe 100644 --- a/pkg/util/sem/v2/restricted_hint_test.go +++ b/pkg/util/sem/v2/restricted_hint_test.go @@ -21,17 +21,20 @@ import ( "github.com/stretchr/testify/require" ) -func TestHint_NoSEM(t *testing.T) { +func TestHint_StrictDisabled(t *testing.T) { + // Without strict SEM, all hints pass — including resource_group, since + // the helper self-gates on IsStrictEnabled. require.NoError(t, IsRestrictedHint("memory_quota")) require.NoError(t, IsRestrictedHint("read_consistent_replica")) require.NoError(t, IsRestrictedHint("max_execution_time")) - // resource_group is rejected unconditionally. - require.Error(t, IsRestrictedHint("resource_group")) + require.NoError(t, IsRestrictedHint("resource_group")) require.NoError(t, IsRestrictedHint("use_index")) require.NoError(t, IsRestrictedHint("")) } -func TestHint_WithSEMHidden(t *testing.T) { +func TestHint_StrictWithSEMHidden(t *testing.T) { + EnableStrict() + t.Cleanup(DisableStrict) t.Cleanup(Disable) require.NoError(t, EnableBy(&Config{ TiDBVersion: "v0.0.0", @@ -49,7 +52,9 @@ func TestHint_WithSEMHidden(t *testing.T) { require.NoError(t, IsRestrictedHint("use_index")) } -func TestHint_WithSEMReadonly(t *testing.T) { +func TestHint_StrictWithSEMReadonly(t *testing.T) { + EnableStrict() + t.Cleanup(DisableStrict) t.Cleanup(Disable) require.NoError(t, EnableBy(&Config{ TiDBVersion: "v0.0.0", @@ -65,10 +70,13 @@ func TestHint_WithSEMReadonly(t *testing.T) { require.Error(t, IsRestrictedHint("max_execution_time")) } -func TestHint_WithSEMUnrestricted(t *testing.T) { +func TestHint_StrictWithSEMUnrestricted(t *testing.T) { + EnableStrict() + t.Cleanup(DisableStrict) t.Cleanup(Disable) require.NoError(t, EnableBy(&Config{TiDBVersion: "v0.0.0"})) + // Without sysvar restrictions, only resource_group is rejected. require.NoError(t, IsRestrictedHint("memory_quota")) require.NoError(t, IsRestrictedHint("read_consistent_replica")) require.NoError(t, IsRestrictedHint("max_execution_time")) diff --git a/tests/realtikvtest/pipelineddmltest/BUILD.bazel b/tests/realtikvtest/pipelineddmltest/BUILD.bazel index 5a178b9dcad27..92e2633806130 100644 --- a/tests/realtikvtest/pipelineddmltest/BUILD.bazel +++ b/tests/realtikvtest/pipelineddmltest/BUILD.bazel @@ -15,6 +15,7 @@ go_test( "//pkg/kv", "//pkg/sessionctx/vardef", "//pkg/testkit", + "//pkg/util/sem/v2:sem", "//tests/realtikvtest", "@com_github_pingcap_failpoint//:failpoint", "@com_github_stretchr_testify//require", diff --git a/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go b/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go index cf1dfb295b1c9..eafe567ac4ec4 100644 --- a/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go +++ b/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go @@ -29,6 +29,7 @@ import ( "github.com/pingcap/tidb/pkg/kv" "github.com/pingcap/tidb/pkg/sessionctx/vardef" "github.com/pingcap/tidb/pkg/testkit" + semv2 "github.com/pingcap/tidb/pkg/util/sem/v2" "github.com/pingcap/tidb/tests/realtikvtest" "github.com/stretchr/testify/require" ) @@ -343,6 +344,12 @@ func TestPipelinedDMLNegative(t *testing.T) { tk.MustExec("insert into t values(11, 11)") tk.MustQuery("show warnings").CheckContain("Pipelined DML can not be used when tidb_constraint_check_in_place=ON. Fallback to standard mode") tk.MustExec("set @@tidb_constraint_check_in_place = 0") + + // strict SEM (starter/essential deployments) + semv2.EnableStrict() + tk.MustExec("insert into t values(12, 12)") + tk.MustQuery("show warnings").CheckContain("Pipelined DML is not supported in this deployment. Fallback to standard mode") + semv2.DisableStrict() } func compareTables(t *testing.T, tk *testkit.TestKit, t1, t2 string) { From f4b0816907ce134bbf59abc87a3489b7870757bf Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Mon, 18 May 2026 19:00:04 +0800 Subject: [PATCH 08/10] address comments Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- pkg/util/sem/v2/restricted_statement.go | 68 ++++++++++++++++++- pkg/util/sem/v2/restricted_statement_test.go | 19 ++++++ .../pipelineddmltest/pipelineddml_test.go | 5 +- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/pkg/util/sem/v2/restricted_statement.go b/pkg/util/sem/v2/restricted_statement.go index 81ac0ad095d67..964bf0224eeaa 100644 --- a/pkg/util/sem/v2/restricted_statement.go +++ b/pkg/util/sem/v2/restricted_statement.go @@ -40,6 +40,10 @@ func isRestrictedRole(userName, hostname string) bool { // IsRestrictedStatement returns a non-nil error when strict SEM forbids stmt. // It runs the check unconditionally; callers gate on IsStrictEnabled. +// +// The switch is a default-deny allow-list: any StmtNode not matched below is +// rejected by the trailing notSupported call. When adding a new statement +// type, decide explicitly whether it should be allowed or rejected. func IsRestrictedStatement(stmt ast.Node) error { switch x := stmt.(type) { case *ast.DeallocateStmt, @@ -62,7 +66,28 @@ func IsRestrictedStatement(stmt ast.Node) error { *ast.CreateBindingStmt, *ast.DropBindingStmt, *ast.SetBindingStmt, - *ast.CompactTableStmt: + *ast.CompactTableStmt, + // Carried over from the previous default-allow fall-through. They are + // preserved as allow here to keep behavior identical to before the + // switch was tightened to default-deny; revisit each in a follow-up. + *ast.AddQueryWatchStmt, + *ast.AlterRangeStmt, + *ast.CalibrateResourceStmt, + *ast.CallStmt, + *ast.CancelDistributionJobStmt, + *ast.CreateStatisticsStmt, + *ast.DistributeTableStmt, + *ast.DropProcedureStmt, + *ast.DropQueryWatchStmt, + *ast.DropStatisticsStmt, + *ast.GrantProxyStmt, + *ast.HelpStmt, + *ast.ImportIntoActionStmt, + *ast.ImportIntoStmt, + *ast.RecommendIndexStmt, + *ast.RefreshStatsStmt, + *ast.RestartStmt, + *ast.TrafficStmt: return nil case *ast.LoadDataStmt: return verifyLoadData(x) @@ -87,7 +112,7 @@ func IsRestrictedStatement(stmt ast.Node) error { return notSupported("SPLIT REGION") } - return nil + return notSupported(fmt.Sprintf("Unsupported statement: %T", stmt)) } func verifyDDL(stmt ast.DDLNode) error { @@ -158,7 +183,6 @@ func verifySimple(stmt ast.Node) error { *ast.KillStmt, *ast.BinlogStmt, *ast.DropStatsStmt, - *ast.SetDefaultRoleStmt, *ast.AdminStmt, *ast.GrantStmt, *ast.RevokeStmt, @@ -194,12 +218,50 @@ func verifySimple(stmt ast.Node) error { } return nil case *ast.SetRoleStmt: + // SET ROLE DEFAULT|ALL|ALL EXCEPT can implicitly activate a granted + // restricted role (cloud_admin), bypassing the RoleList-only check. + // Reject those wildcard forms; allow NONE (deactivates everything) + // and the regular form after the RoleList contains no restricted role. + switch s.SetRoleOpt { + case ast.SetRoleNone: + return nil + case ast.SetRoleDefault: + return notSupported("SET ROLE DEFAULT") + case ast.SetRoleAll: + return notSupported("SET ROLE ALL") + case ast.SetRoleAllExcept: + return notSupported("SET ROLE ALL EXCEPT") + } for _, role := range s.RoleList { if isRestrictedRole(role.Username, role.Hostname) { return notSupported(fmt.Sprintf("SET ROLE %s", role)) } } return nil + case *ast.SetDefaultRoleStmt: + // SET DEFAULT ROLE is the matching write-side: it persists the role + // list a user activates via SET ROLE DEFAULT. Block any attempt to + // (a) target a restricted account (root, cloud_admin) so its default + // role set cannot be tampered with, (b) install a restricted role as + // someone else's default, and (c) use ALL, which would silently + // include cloud_admin when it has been granted to the target. + for _, user := range s.UserList { + if isRestrictedUser(user.Username, user.Hostname) { + return notSupported(fmt.Sprintf("SET DEFAULT ROLE TO %s", user)) + } + } + switch s.SetRoleOpt { + case ast.SetRoleNone: + return nil + case ast.SetRoleAll: + return notSupported("SET DEFAULT ROLE ALL") + } + for _, role := range s.RoleList { + if isRestrictedRole(role.Username, role.Hostname) { + return notSupported(fmt.Sprintf("SET DEFAULT ROLE %s", role)) + } + } + return nil case *ast.AlterInstanceStmt: return notSupported("ALTER INSTANCE") case *ast.ShutdownStmt: diff --git a/pkg/util/sem/v2/restricted_statement_test.go b/pkg/util/sem/v2/restricted_statement_test.go index 8718f87b48806..f7b64e7cb0441 100644 --- a/pkg/util/sem/v2/restricted_statement_test.go +++ b/pkg/util/sem/v2/restricted_statement_test.go @@ -147,6 +147,25 @@ func TestStatement_RestrictedUserOperations(t *testing.T) { mustReject(t, `SET ROLE 'cloud_admin'@'%'`, "SET ROLE") mustPass(t, `SET ROLE 'role1'@'%'`) + // SET ROLE wildcard forms can implicitly activate cloud_admin if granted, + // so they are blocked outright. NONE is safe (deactivates everything). + mustReject(t, `SET ROLE DEFAULT`, "SET ROLE DEFAULT") + mustReject(t, `SET ROLE ALL`, "SET ROLE ALL") + mustReject(t, `SET ROLE ALL EXCEPT 'role1'@'%'`, "SET ROLE ALL EXCEPT") + mustPass(t, `SET ROLE NONE`) + + // SET DEFAULT ROLE writes the role list activated by SET ROLE DEFAULT, so + // it must enforce the same protection on both ends: no restricted role can + // be installed as a default, the target user list cannot include a + // restricted account, and ALL is rejected because it would silently include + // cloud_admin when granted. + mustReject(t, `SET DEFAULT ROLE 'cloud_admin'@'%' TO 'alice'@'%'`, "SET DEFAULT ROLE") + mustReject(t, `SET DEFAULT ROLE 'role1'@'%' TO 'root'@'%'`, "SET DEFAULT ROLE TO") + mustReject(t, `SET DEFAULT ROLE 'role1'@'%' TO 'cloud_admin'@'%'`, "SET DEFAULT ROLE TO") + mustReject(t, `SET DEFAULT ROLE ALL TO 'alice'@'%'`, "SET DEFAULT ROLE ALL") + mustPass(t, `SET DEFAULT ROLE 'role1'@'%' TO 'alice'@'%'`) + mustPass(t, `SET DEFAULT ROLE NONE TO 'alice'@'%'`) + mustPass(t, `CREATE USER 'x'@'%' IDENTIFIED BY 'pw'`) mustPass(t, `ALTER USER 'x'@'%' IDENTIFIED BY 'pw2'`) mustPass(t, `SET PASSWORD FOR 'x'@'%' = 'pw3'`) diff --git a/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go b/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go index eafe567ac4ec4..119adaed79433 100644 --- a/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go +++ b/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go @@ -345,11 +345,12 @@ func TestPipelinedDMLNegative(t *testing.T) { tk.MustQuery("show warnings").CheckContain("Pipelined DML can not be used when tidb_constraint_check_in_place=ON. Fallback to standard mode") tk.MustExec("set @@tidb_constraint_check_in_place = 0") - // strict SEM (starter/essential deployments) + // strict SEM (starter/essential deployments). Use t.Cleanup so a mid-test + // assertion failure cannot leak the global strict flag into later tests. semv2.EnableStrict() + t.Cleanup(semv2.DisableStrict) tk.MustExec("insert into t values(12, 12)") tk.MustQuery("show warnings").CheckContain("Pipelined DML is not supported in this deployment. Fallback to standard mode") - semv2.DisableStrict() } func compareTables(t *testing.T, tk *testkit.TestKit, t1, t2 string) { From 70eafd8f2a5fcc0d6a8c2d6c319da2d49335ad7b Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Mon, 18 May 2026 21:26:51 +0800 Subject: [PATCH 09/10] address comments Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- cmd/tidb-server/main.go | 12 +++++++++--- pkg/util/sem/v2/restricted_statement.go | 14 ++++++++------ pkg/util/sem/v2/restricted_statement_test.go | 11 +++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/cmd/tidb-server/main.go b/cmd/tidb-server/main.go index ac47e98287e2c..5f40a0510da08 100644 --- a/cmd/tidb-server/main.go +++ b/cmd/tidb-server/main.go @@ -1200,9 +1200,11 @@ func setupSEM() { cfg := config.GetGlobalConfig() // Strict SEM is gated on the Starter deployment mode (next-gen only; - // deploymode.IsStarter already returns false on classic builds), and it - // layers on top of an enabled SEM, so it still requires - // security.enable-sem=true. + // deploymode.IsStarter already returns false on classic builds). It + // layers on top of an enabled semv2 (not classic sem), so it requires + // both security.enable-sem=true and a non-empty security.sem-config — + // the hint allow-list reads sysvar visibility via semv2 helpers, which + // silently return false when semv2 is uninitialized. strictSEM := deploymode.IsStarter() if !cfg.Security.EnableSEM { @@ -1218,6 +1220,10 @@ func setupSEM() { logutil.BgLogger().Fatal("failed to enable SEM", zap.Error(err)) } } else { + if strictSEM { + logutil.BgLogger().Warn("Starter deployment mode requires security.sem-config to enable strict SEM; ignoring") + strictSEM = false + } sem.Enable() } diff --git a/pkg/util/sem/v2/restricted_statement.go b/pkg/util/sem/v2/restricted_statement.go index 964bf0224eeaa..081eafe4fdfba 100644 --- a/pkg/util/sem/v2/restricted_statement.go +++ b/pkg/util/sem/v2/restricted_statement.go @@ -22,9 +22,12 @@ import ( "github.com/pingcap/tidb/pkg/util/dbterror/plannererrors" ) -// Literal identifiers protected from DROP/RENAME/role-change operations. -// tidb-cse drives these off a keyspace username policy; upstream has no such -// concept, so we match the raw names at host '%'. +// Literal identifiers protected from account modification: restrictedUsers +// cannot be dropped, renamed, or have their default-role list rewritten; +// restrictedRoles cannot be granted, revoked, activated via SET ROLE, or +// installed as anyone's default. tidb-cse drives these off a keyspace +// username policy; upstream has no such concept, so we match the raw names +// at host '%'. var ( restrictedUsers = []string{"cloud_admin", "root"} restrictedRoles = []string{"cloud_admin"} @@ -183,7 +186,6 @@ func verifySimple(stmt ast.Node) error { *ast.KillStmt, *ast.BinlogStmt, *ast.DropStatsStmt, - *ast.AdminStmt, *ast.GrantStmt, *ast.RevokeStmt, *ast.NonTransactionalDMLStmt, @@ -271,7 +273,7 @@ func verifySimple(stmt ast.Node) error { case *ast.SetResourceGroupStmt: return notSupported("SET RESOURCE GROUP") } - return notSupported(fmt.Sprintf("Unsupported Executor %T", stmt)) + return notSupported(fmt.Sprintf("Unsupported statement: %T", stmt)) } func verifyBRIE(stmt *ast.BRIEStmt) error { @@ -399,7 +401,7 @@ func verifyAdmin(stmt *ast.AdminStmt) error { case ast.AdminShowSlow: return notSupported("ADMIN SHOW SLOW") } - return notSupported(fmt.Sprintf("Unsupported statement: %T", stmt)) + return notSupported(fmt.Sprintf("Unsupported ADMIN type %d", stmt.Tp)) } func notSupported(feature string) error { diff --git a/pkg/util/sem/v2/restricted_statement_test.go b/pkg/util/sem/v2/restricted_statement_test.go index f7b64e7cb0441..8d99ec774af12 100644 --- a/pkg/util/sem/v2/restricted_statement_test.go +++ b/pkg/util/sem/v2/restricted_statement_test.go @@ -250,3 +250,14 @@ func TestStatement_Transactional(t *testing.T) { mustPass(t, sql) } } + +// TestStatement_DefaultDeny pins the allow-list semantics: an unrecognized +// StmtNode must be rejected. SearchCaseStmt is a real StmtNode that is only +// valid inside stored-procedure bodies, so the top-level dispatcher never +// lists it — this is the closest stand-in for "some future stmt type the +// dispatcher does not know about yet." +func TestStatement_DefaultDeny(t *testing.T) { + err := IsRestrictedStatement(&ast.SearchCaseStmt{}) + require.Error(t, err, "default-deny: unknown StmtNode must be rejected") + require.Contains(t, err.Error(), "Unsupported statement") +} From 3dfb47cd247ac3daff8e81d96058ba45c682be17 Mon Sep 17 00:00:00 2001 From: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> Date: Mon, 18 May 2026 21:37:11 +0800 Subject: [PATCH 10/10] optimize comment Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com> --- cmd/tidb-server/main.go | 8 ++--- pkg/util/sem/v2/restricted_hint.go | 6 ++-- pkg/util/sem/v2/restricted_statement.go | 31 +++++-------------- pkg/util/sem/v2/restricted_statement_test.go | 14 ++------- pkg/util/sem/v2/strict.go | 3 +- .../pipelineddmltest/pipelineddml_test.go | 3 +- 6 files changed, 15 insertions(+), 50 deletions(-) diff --git a/cmd/tidb-server/main.go b/cmd/tidb-server/main.go index 5f40a0510da08..cb062b0c37c26 100644 --- a/cmd/tidb-server/main.go +++ b/cmd/tidb-server/main.go @@ -1199,12 +1199,8 @@ func enablePyroscope() { func setupSEM() { cfg := config.GetGlobalConfig() - // Strict SEM is gated on the Starter deployment mode (next-gen only; - // deploymode.IsStarter already returns false on classic builds). It - // layers on top of an enabled semv2 (not classic sem), so it requires - // both security.enable-sem=true and a non-empty security.sem-config — - // the hint allow-list reads sysvar visibility via semv2 helpers, which - // silently return false when semv2 is uninitialized. + // Strict SEM is Starter-only and depends on semv2 being initialized + // (the hint allow-list reads sysvar visibility from semv2 state). strictSEM := deploymode.IsStarter() if !cfg.Security.EnableSEM { diff --git a/pkg/util/sem/v2/restricted_hint.go b/pkg/util/sem/v2/restricted_hint.go index a27f38e1af661..6519c47c17c30 100644 --- a/pkg/util/sem/v2/restricted_hint.go +++ b/pkg/util/sem/v2/restricted_hint.go @@ -20,10 +20,8 @@ import ( ) // IsRestrictedHint returns a non-nil error when strict SEM forbids the hint. -// memory_quota / read_consistent_replica / max_execution_time piggyback on -// sysvar visibility (a hint is rejected only when the corresponding sysvar -// is itself hidden or read-only). resource_group is rejected outright because -// resource group management is unavailable to starter/essential deployments. +// memory_quota/read_consistent_replica/max_execution_time piggyback on sysvar +// visibility; resource_group is rejected outright (unavailable in starter). func IsRestrictedHint(hintNameLower string) error { if !IsStrictEnabled() { return nil diff --git a/pkg/util/sem/v2/restricted_statement.go b/pkg/util/sem/v2/restricted_statement.go index 081eafe4fdfba..0b0c828d1f5b5 100644 --- a/pkg/util/sem/v2/restricted_statement.go +++ b/pkg/util/sem/v2/restricted_statement.go @@ -22,12 +22,6 @@ import ( "github.com/pingcap/tidb/pkg/util/dbterror/plannererrors" ) -// Literal identifiers protected from account modification: restrictedUsers -// cannot be dropped, renamed, or have their default-role list rewritten; -// restrictedRoles cannot be granted, revoked, activated via SET ROLE, or -// installed as anyone's default. tidb-cse drives these off a keyspace -// username policy; upstream has no such concept, so we match the raw names -// at host '%'. var ( restrictedUsers = []string{"cloud_admin", "root"} restrictedRoles = []string{"cloud_admin"} @@ -42,11 +36,7 @@ func isRestrictedRole(userName, hostname string) bool { } // IsRestrictedStatement returns a non-nil error when strict SEM forbids stmt. -// It runs the check unconditionally; callers gate on IsStrictEnabled. -// -// The switch is a default-deny allow-list: any StmtNode not matched below is -// rejected by the trailing notSupported call. When adding a new statement -// type, decide explicitly whether it should be allowed or rejected. +// Callers gate on IsStrictEnabled. The switch is a default-deny allow-list. func IsRestrictedStatement(stmt ast.Node) error { switch x := stmt.(type) { case *ast.DeallocateStmt, @@ -70,9 +60,8 @@ func IsRestrictedStatement(stmt ast.Node) error { *ast.DropBindingStmt, *ast.SetBindingStmt, *ast.CompactTableStmt, - // Carried over from the previous default-allow fall-through. They are - // preserved as allow here to keep behavior identical to before the - // switch was tightened to default-deny; revisit each in a follow-up. + // Preserved from the previous default-allow fall-through; each is + // a follow-up candidate to reject explicitly. *ast.AddQueryWatchStmt, *ast.AlterRangeStmt, *ast.CalibrateResourceStmt, @@ -220,10 +209,8 @@ func verifySimple(stmt ast.Node) error { } return nil case *ast.SetRoleStmt: - // SET ROLE DEFAULT|ALL|ALL EXCEPT can implicitly activate a granted - // restricted role (cloud_admin), bypassing the RoleList-only check. - // Reject those wildcard forms; allow NONE (deactivates everything) - // and the regular form after the RoleList contains no restricted role. + // Wildcard forms can activate cloud_admin if granted, bypassing + // the RoleList check. switch s.SetRoleOpt { case ast.SetRoleNone: return nil @@ -241,12 +228,8 @@ func verifySimple(stmt ast.Node) error { } return nil case *ast.SetDefaultRoleStmt: - // SET DEFAULT ROLE is the matching write-side: it persists the role - // list a user activates via SET ROLE DEFAULT. Block any attempt to - // (a) target a restricted account (root, cloud_admin) so its default - // role set cannot be tampered with, (b) install a restricted role as - // someone else's default, and (c) use ALL, which would silently - // include cloud_admin when it has been granted to the target. + // Write-side companion to SET ROLE DEFAULT: block restricted users + // as targets, restricted roles as defaults, and ALL. for _, user := range s.UserList { if isRestrictedUser(user.Username, user.Hostname) { return notSupported(fmt.Sprintf("SET DEFAULT ROLE TO %s", user)) diff --git a/pkg/util/sem/v2/restricted_statement_test.go b/pkg/util/sem/v2/restricted_statement_test.go index 8d99ec774af12..0fcb13c5a2fce 100644 --- a/pkg/util/sem/v2/restricted_statement_test.go +++ b/pkg/util/sem/v2/restricted_statement_test.go @@ -147,18 +147,11 @@ func TestStatement_RestrictedUserOperations(t *testing.T) { mustReject(t, `SET ROLE 'cloud_admin'@'%'`, "SET ROLE") mustPass(t, `SET ROLE 'role1'@'%'`) - // SET ROLE wildcard forms can implicitly activate cloud_admin if granted, - // so they are blocked outright. NONE is safe (deactivates everything). mustReject(t, `SET ROLE DEFAULT`, "SET ROLE DEFAULT") mustReject(t, `SET ROLE ALL`, "SET ROLE ALL") mustReject(t, `SET ROLE ALL EXCEPT 'role1'@'%'`, "SET ROLE ALL EXCEPT") mustPass(t, `SET ROLE NONE`) - // SET DEFAULT ROLE writes the role list activated by SET ROLE DEFAULT, so - // it must enforce the same protection on both ends: no restricted role can - // be installed as a default, the target user list cannot include a - // restricted account, and ALL is rejected because it would silently include - // cloud_admin when granted. mustReject(t, `SET DEFAULT ROLE 'cloud_admin'@'%' TO 'alice'@'%'`, "SET DEFAULT ROLE") mustReject(t, `SET DEFAULT ROLE 'role1'@'%' TO 'root'@'%'`, "SET DEFAULT ROLE TO") mustReject(t, `SET DEFAULT ROLE 'role1'@'%' TO 'cloud_admin'@'%'`, "SET DEFAULT ROLE TO") @@ -251,11 +244,8 @@ func TestStatement_Transactional(t *testing.T) { } } -// TestStatement_DefaultDeny pins the allow-list semantics: an unrecognized -// StmtNode must be rejected. SearchCaseStmt is a real StmtNode that is only -// valid inside stored-procedure bodies, so the top-level dispatcher never -// lists it — this is the closest stand-in for "some future stmt type the -// dispatcher does not know about yet." +// SearchCaseStmt is a procedure-body StmtNode that the top-level dispatcher +// never lists, so it stands in for an unknown future statement type. func TestStatement_DefaultDeny(t *testing.T) { err := IsRestrictedStatement(&ast.SearchCaseStmt{}) require.Error(t, err, "default-deny: unknown StmtNode must be rejected") diff --git a/pkg/util/sem/v2/strict.go b/pkg/util/sem/v2/strict.go index 08f1f8d7c3c44..880164ebf92a7 100644 --- a/pkg/util/sem/v2/strict.go +++ b/pkg/util/sem/v2/strict.go @@ -18,8 +18,7 @@ import "sync/atomic" var strictEnabled atomic.Bool -// EnableStrict turns on the strict-SEM allow-list (restricted statements and -// hints) used in next-gen deployments. +// EnableStrict turns on the strict-SEM allow-list used in next-gen deployments. func EnableStrict() { strictEnabled.Store(true) } // DisableStrict is test-only. diff --git a/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go b/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go index 119adaed79433..a76a487f4dc90 100644 --- a/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go +++ b/tests/realtikvtest/pipelineddmltest/pipelineddml_test.go @@ -345,8 +345,7 @@ func TestPipelinedDMLNegative(t *testing.T) { tk.MustQuery("show warnings").CheckContain("Pipelined DML can not be used when tidb_constraint_check_in_place=ON. Fallback to standard mode") tk.MustExec("set @@tidb_constraint_check_in_place = 0") - // strict SEM (starter/essential deployments). Use t.Cleanup so a mid-test - // assertion failure cannot leak the global strict flag into later tests. + // strict SEM (starter/essential deployments) semv2.EnableStrict() t.Cleanup(semv2.DisableStrict) tk.MustExec("insert into t values(12, 12)")