diff --git a/backend/controllers/config_error.go b/backend/controllers/config_error.go index dc99149..4d23268 100644 --- a/backend/controllers/config_error.go +++ b/backend/controllers/config_error.go @@ -131,6 +131,10 @@ func configErrorDetails(err error) (string, int, string, map[string]string) { code = "DATABASE_MIGRATION_CONNECTION_UNAVAILABLE" status = http.StatusServiceUnavailable message = "数据库连接暂不可用,请检查数据库配置和服务状态" + case errors.Is(err, dbmigration.ErrSQLPermissionDenied): + code = "DATABASE_MIGRATION_PERMISSION_DENIED" + status = http.StatusUnprocessableEntity + message = "目标数据库账号权限不足,请联系数据库管理员授予建库权限后重新预览;PostgreSQL 需要 CREATEDB 权限,迁移完成后可撤销" case errors.Is(err, dbmigration.ErrSQLUnsupportedObject): code = "DATABASE_MIGRATION_UNSUPPORTED_OBJECT" status = http.StatusUnprocessableEntity diff --git a/backend/controllers/config_error_test.go b/backend/controllers/config_error_test.go index 1d5fc43..dd33259 100644 --- a/backend/controllers/config_error_test.go +++ b/backend/controllers/config_error_test.go @@ -139,6 +139,13 @@ func TestConfigErrorDetailsMapsDatabaseMigrationFailures(t *testing.T) { status: 503, message: "数据库连接暂不可用,请检查数据库配置和服务状态", }, + { + name: "target creation permission denied", + err: dbmigration.ErrSQLPermissionDenied, + code: "DATABASE_MIGRATION_PERMISSION_DENIED", + status: 422, + message: "目标数据库账号权限不足,请联系数据库管理员授予建库权限后重新预览;PostgreSQL 需要 CREATEDB 权限,迁移完成后可撤销", + }, { name: "unsupported object", err: dbmigration.ErrSQLUnsupportedObject, diff --git a/backend/services/dbmigration/portable_connection_test.go b/backend/services/dbmigration/portable_connection_test.go index 8b9f0e1..fb54701 100644 --- a/backend/services/dbmigration/portable_connection_test.go +++ b/backend/services/dbmigration/portable_connection_test.go @@ -1,10 +1,18 @@ package dbmigration import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" "net/url" + "strings" "testing" "git.pcux.cn/xuans/Smail/config" + mysql "github.com/go-sql-driver/mysql" "github.com/lib/pq" ) @@ -32,3 +40,135 @@ func TestPortablePostgresUsesSupportedRuntimeTLSMode(t *testing.T) { } } } + +func TestDatabaseCreationErrorClassification(t *testing.T) { + for _, test := range []struct { + name string + kind config.DatabaseType + err error + want error + }{ + {"postgres permission", config.DatabaseTypePostgreSQL, &pq.Error{Code: "42501"}, ErrSQLPermissionDenied}, + {"postgres wrapped permission", config.DatabaseTypePostgreSQL, fmt.Errorf("private detail: %w", &pq.Error{Code: "42501"}), ErrSQLPermissionDenied}, + {"postgres duplicate", config.DatabaseTypePostgreSQL, &pq.Error{Code: "42P04"}, ErrSQLTargetExists}, + {"mysql database permission", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1044}, ErrSQLPermissionDenied}, + {"mysql command permission", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1142}, ErrSQLPermissionDenied}, + {"mysql privilege", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1227}, ErrSQLPermissionDenied}, + {"mysql duplicate", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1007}, ErrSQLTargetExists}, + {"authentication", config.DatabaseTypePostgreSQL, &pq.Error{Code: "28P01"}, ErrSQLConnectionUnavailable}, + {"network", config.DatabaseTypePostgreSQL, errors.New("private connection detail"), ErrSQLConnectionUnavailable}, + } { + t.Run(test.name, func(t *testing.T) { + if got := classifyDatabaseCreationError(test.err, test.kind); got != test.want { + t.Fatalf("classification = %v, want %v", got, test.want) + } + }) + } +} + +type permissionCatalog struct { + exists int64 + allowed bool + queryErr error + createErr error + queries int + creates int +} + +func (c *permissionCatalog) Connect(context.Context) (driver.Conn, error) { return c, nil } +func (c *permissionCatalog) Driver() driver.Driver { return c } +func (c *permissionCatalog) Open(string) (driver.Conn, error) { return c, nil } +func (c *permissionCatalog) Close() error { return nil } +func (c *permissionCatalog) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} +func (c *permissionCatalog) Begin() (driver.Tx, error) { + return nil, errors.New("unexpected transaction") +} +func (c *permissionCatalog) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + switch query { + case "SELECT COUNT(*) FROM pg_database WHERE datname = $1": + if len(args) != 1 || args[0].Value != "smail_next" { + return nil, errors.New("unexpected target name") + } + return &permissionCatalogRow{value: c.exists}, nil + case "SELECT rolcreatedb OR rolsuper FROM pg_roles WHERE rolname = CURRENT_USER": + c.queries++ + if c.queryErr != nil { + return nil, c.queryErr + } + return &permissionCatalogRow{value: c.allowed}, nil + default: + return nil, errors.New("unexpected catalog query") + } +} +func (c *permissionCatalog) ExecContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) { + if !strings.HasPrefix(query, "CREATE DATABASE ") { + return nil, errors.New("unexpected write") + } + c.creates++ + return nil, c.createErr +} + +type permissionCatalogRow struct { + value driver.Value + done bool +} + +func (r *permissionCatalogRow) Columns() []string { return []string{"value"} } +func (r *permissionCatalogRow) Close() error { return nil } +func (r *permissionCatalogRow) Next(values []driver.Value) error { + if r.done { + return io.EOF + } + r.done = true + values[0] = r.value + return nil +} + +func TestPortablePostgresPermissionPreviewIsReadOnly(t *testing.T) { + for _, test := range []struct { + name string + catalog permissionCatalog + want error + }{ + {"denied", permissionCatalog{}, ErrSQLPermissionDenied}, + {"allowed", permissionCatalog{allowed: true}, nil}, + {"existing", permissionCatalog{exists: 1}, ErrSQLTargetExists}, + {"catalog unavailable", permissionCatalog{queryErr: errors.New("unavailable")}, ErrSQLConnectionUnavailable}, + } { + t.Run(test.name, func(t *testing.T) { + root, source := portableFixture(t) + target := testDatabase(t) + target.Type = config.DatabaseTypePostgreSQL + target.Name = "smail_next" + plan, err := buildTargetPlan(source, target) + if err != nil { + t.Fatal(err) + } + executor := &PortableExecutor{root: root, open: func(context.Context, config.DatabaseConfig, string) (*sql.DB, error) { + return sql.OpenDB(&test.catalog), nil + }} + if err := executor.Preview(context.Background(), plan); !errors.Is(err, test.want) { + t.Fatalf("preview = %v, want %v", err, test.want) + } + if test.catalog.creates != 0 || executor.paused.Load() || executor.pending { + t.Fatal("preview wrote target or paused source") + } + }) + } +} + +func TestPortablePostgresCreationHandlesPermissionRevocation(t *testing.T) { + catalog := &permissionCatalog{allowed: true, createErr: &pq.Error{Code: "42501", Message: "private database detail"}} + executor := &PortableExecutor{open: func(context.Context, config.DatabaseConfig, string) (*sql.DB, error) { + return sql.OpenDB(catalog), nil + }} + target := testDatabase(t) + target.Type = config.DatabaseTypePostgreSQL + target.Name = "smail_next" + connection, err := executor.createTarget(context.Background(), target) + if connection != nil || err != ErrSQLPermissionDenied || catalog.creates != 1 || catalog.queries != 1 { + t.Fatalf("creation did not preserve permission failure: %v", err) + } +} diff --git a/backend/services/dbmigration/portable_executor.go b/backend/services/dbmigration/portable_executor.go index 6483280..9bf4523 100644 --- a/backend/services/dbmigration/portable_executor.go +++ b/backend/services/dbmigration/portable_executor.go @@ -96,6 +96,16 @@ func (e *PortableExecutor) checkTarget(ctx context.Context, target config.Databa if count != 0 { return ErrSQLTargetExists } + if target.Type == config.DatabaseTypePostgreSQL { + var canCreate bool + query := "SELECT rolcreatedb OR rolsuper FROM pg_roles WHERE rolname = CURRENT_USER" + if err := admin.QueryRowContext(ctx, query).Scan(&canCreate); err != nil { + return classifyDatabaseCreationError(err, target.Type) + } + if !canCreate { + return ErrSQLPermissionDenied + } + } return nil } @@ -129,10 +139,7 @@ func (e *PortableExecutor) createTarget(ctx context.Context, target config.Datab query += " TEMPLATE template0 ENCODING 'UTF8'" } if _, err := admin.ExecContext(ctx, query); err != nil { - if isDatabaseAlreadyExistsError(err, target.Type) { - return nil, ErrSQLTargetExists - } - return nil, ErrSQLConnectionUnavailable + return nil, classifyDatabaseCreationError(err, target.Type) } } // 失败目标保留供检查,后续重试必须换用未占用的名称,绝不删除旧库。 diff --git a/backend/services/dbmigration/service.go b/backend/services/dbmigration/service.go index 752f46b..bf854a8 100644 --- a/backend/services/dbmigration/service.go +++ b/backend/services/dbmigration/service.go @@ -372,6 +372,8 @@ func migrationErrorCode(err error) string { return "COPIER_UNAVAILABLE" case errors.Is(err, ErrSQLConnectionUnavailable): return "CONNECTION_UNAVAILABLE" + case errors.Is(err, ErrSQLPermissionDenied): + return "PERMISSION_DENIED" case errors.Is(err, ErrSQLUnsupportedObject): return "UNSUPPORTED_OBJECT" case errors.Is(err, ErrSQLDataVerification): diff --git a/backend/services/dbmigration/service_test.go b/backend/services/dbmigration/service_test.go index 4d7450e..3cd04d1 100644 --- a/backend/services/dbmigration/service_test.go +++ b/backend/services/dbmigration/service_test.go @@ -119,6 +119,7 @@ func TestMigrationErrorCodeMapsExecutorFailures(t *testing.T) { {err: ErrSQLTargetExists, code: "TARGET_EXISTS"}, {err: ErrSQLCopyUnavailable, code: "COPIER_UNAVAILABLE"}, {err: ErrSQLConnectionUnavailable, code: "CONNECTION_UNAVAILABLE"}, + {err: ErrSQLPermissionDenied, code: "PERMISSION_DENIED"}, {err: ErrSQLUnsupportedObject, code: "UNSUPPORTED_OBJECT"}, {err: ErrSQLDataVerification, code: "DATA_VERIFICATION_FAILED"}, {err: ErrSQLSourceBusy, code: "SOURCE_BUSY"}, diff --git a/backend/services/dbmigration/sql_executor.go b/backend/services/dbmigration/sql_executor.go index 620a306..6fb635c 100644 --- a/backend/services/dbmigration/sql_executor.go +++ b/backend/services/dbmigration/sql_executor.go @@ -25,6 +25,7 @@ var ( ErrSQLTargetExists = errors.New("structured database migration target already exists") ErrSQLUnsupportedObject = errors.New("structured database contains unsupported object") ErrSQLConnectionUnavailable = errors.New("structured database connection unavailable") + ErrSQLPermissionDenied = errors.New("structured database migration permission denied") ErrSQLDataVerification = errors.New("structured database data verification failed") ErrSQLSourceBusy = errors.New("structured database source has active connections") ErrSwitchOutcomeUnknown = errors.New("database configuration switch outcome is unknown") @@ -750,6 +751,25 @@ func listPostgreSQLTables(ctx context.Context, database *sql.DB) ([]postgreSQLTa return tables, nil } +func classifyDatabaseCreationError(err error, databaseType config.DatabaseType) error { + if isDatabaseAlreadyExistsError(err, databaseType) { + return ErrSQLTargetExists + } + switch databaseType { + case config.DatabaseTypePostgreSQL: + var postgresErr *pq.Error + if errors.As(err, &postgresErr) && postgresErr.Code == "42501" { + return ErrSQLPermissionDenied + } + case config.DatabaseTypeMySQL: + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && (mysqlErr.Number == 1044 || mysqlErr.Number == 1142 || mysqlErr.Number == 1227) { + return ErrSQLPermissionDenied + } + } + return ErrSQLConnectionUnavailable +} + func isDatabaseAlreadyExistsError(err error, databaseType config.DatabaseType) bool { if err == nil { return false