diff --git a/cmd/tidb-server/main.go b/cmd/tidb-server/main.go index f8788988b68c1..cb062b0c37c26 100644 --- a/cmd/tidb-server/main.go +++ b/cmd/tidb-server/main.go @@ -1199,14 +1199,32 @@ 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() + // 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 { + if strictSEM { + logutil.BgLogger().Warn("Starter deployment mode requires security.enable-sem=true to enable strict SEM; 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 { + if strictSEM { + logutil.BgLogger().Warn("Starter deployment mode requires security.sem-config to enable strict SEM; ignoring") + strictSEM = false } + sem.Enable() + } + + if strictSEM { + semv2.EnableStrict() + logutil.BgLogger().Info("strict SEM enabled for Starter deployment mode") } } 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 8b54d178b282f..402fcd226e61c 100644 --- a/pkg/planner/core/planbuilder.go +++ b/pkg/planner/core/planbuilder.go @@ -6397,6 +6397,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..62bf962a4b7b5 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,25 @@ 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 + } + filtered := make([]*ast.TableOptimizerHint, 0, len(hints)) + var warns []error + for _, h := range hints { + if err := semv2.IsRestrictedHint(h.HintName.L); err != nil { + warns = append(warns, err) + continue + } + filtered = append(filtered, h) + } + 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 d5471d01ec16f..fc7d6959fd878 100644 --- a/pkg/session/BUILD.bazel +++ b/pkg/session/BUILD.bazel @@ -122,6 +122,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 aef2cdda6348c..275244908a1a7 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" @@ -5641,6 +5642,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..a54c69803ac12 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,6 +34,8 @@ go_test( timeout = "short", srcs = [ "config_test.go", + "restricted_hint_test.go", + "restricted_statement_test.go", "sem_test.go", "sql_rule_test.go", ], @@ -36,6 +43,7 @@ go_test( 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..6519c47c17c30 --- /dev/null +++ b/pkg/util/sem/v2/restricted_hint.go @@ -0,0 +1,46 @@ +// 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 strict SEM forbids the hint. +// 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 + } + 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..e0320f84e1afe --- /dev/null +++ b/pkg/util/sem/v2/restricted_hint_test.go @@ -0,0 +1,84 @@ +// 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_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")) + require.NoError(t, IsRestrictedHint("resource_group")) + require.NoError(t, IsRestrictedHint("use_index")) + require.NoError(t, IsRestrictedHint("")) +} + +func TestHint_StrictWithSEMHidden(t *testing.T) { + EnableStrict() + t.Cleanup(DisableStrict) + 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_StrictWithSEMReadonly(t *testing.T) { + EnableStrict() + t.Cleanup(DisableStrict) + 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_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")) + 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..0b0c828d1f5b5 --- /dev/null +++ b/pkg/util/sem/v2/restricted_statement.go @@ -0,0 +1,392 @@ +// 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" + "slices" + + "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/pingcap/tidb/pkg/util/dbterror/plannererrors" +) + +var ( + restrictedUsers = []string{"cloud_admin", "root"} + restrictedRoles = []string{"cloud_admin"} +) + +func isRestrictedUser(userName, hostname string) bool { + return hostname == "%" && slices.Contains(restrictedUsers, userName) +} + +func isRestrictedRole(userName, hostname string) bool { + return hostname == "%" && slices.Contains(restrictedRoles, userName) +} + +// IsRestrictedStatement returns a non-nil error when strict SEM forbids stmt. +// 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, + *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, + // Preserved from the previous default-allow fall-through; each is + // a follow-up candidate to reject explicitly. + *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) + case *ast.AdminStmt: + return verifyAdmin(x) + case *ast.LoadStatsStmt: + return notSupported("LOAD STATS") + case *ast.ShowStmt: + return verifyShow(x) + case *ast.SetConfigStmt: + 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, + *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 notSupported(fmt.Sprintf("Unsupported statement: %T", stmt)) +} + +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.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: + // Wildcard forms can activate cloud_admin if granted, bypassing + // the RoleList check. + 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: + // 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)) + } + } + 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: + return notSupported("SHUTDOWN") + case *ast.BRIEStmt: + return verifyBRIE(s) + case *ast.SetResourceGroupStmt: + return notSupported("SET RESOURCE GROUP") + } + return notSupported(fmt.Sprintf("Unsupported statement: %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 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 ADMIN type %d", stmt.Tp)) +} + +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..0fcb13c5a2fce --- /dev/null +++ b/pkg/util/sem/v2/restricted_statement_test.go @@ -0,0 +1,253 @@ +// 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/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.Contains(t, err.Error(), wantSubstr, "sql: %s", 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'@'%'`) + + 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`) + + 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'`) +} + +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) + } +} + +// 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") + require.Contains(t, err.Error(), "Unsupported statement") +} diff --git a/pkg/util/sem/v2/strict.go b/pkg/util/sem/v2/strict.go new file mode 100644 index 0000000000000..880164ebf92a7 --- /dev/null +++ b/pkg/util/sem/v2/strict.go @@ -0,0 +1,28 @@ +// 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 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/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..a76a487f4dc90 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() + 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") } func compareTables(t *testing.T, tk *testkit.TestKit, t1, t2 string) {