diff --git a/backend/internal/database/dns_account_migration_test.go b/backend/internal/database/dns_account_migration_test.go new file mode 100644 index 0000000..e58b2de --- /dev/null +++ b/backend/internal/database/dns_account_migration_test.go @@ -0,0 +1,164 @@ +package database + +import ( + "fmt" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/postgrestest" +) + +func TestSQLiteDNSAccountProjectGuards(t *testing.T) { + for _, foreignKeys := range []int{0, 1} { + for _, recursive := range []int{0, 1} { + t.Run(fmt.Sprintf("foreign_keys_%d_recursive_%d", foreignKeys, recursive), func(t *testing.T) { + cfg := sqliteBusinessUpgradeConfig(t) + cfg.MaxOpenConns, cfg.MaxIdleConns = 1, 1 + db, err := Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, statement := range []string{fmt.Sprintf("PRAGMA foreign_keys=%d", foreignKeys), fmt.Sprintf("PRAGMA recursive_triggers=%d", recursive)} { + if _, err := db.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO projects (id,name,description) VALUES ('dns-only','账户项目','')`); err != nil { + t.Fatal(err) + } + if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO dns_accounts VALUES ('kept','账户','dns-only','retired-owner','aliyun','secret://dns/kept','active','unverified',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `INSERT INTO dns_accounts VALUES ('orphan','账户','missing','owner','aliyun','secret://dns/orphan','active','unverified',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`, + `UPDATE dns_accounts SET project_id='missing' WHERE id='kept'`, + `DELETE FROM projects WHERE id='dns-only'`, + `UPDATE projects SET id='moved' WHERE id='dns-only'`, + `INSERT OR REPLACE INTO projects (id,name,description) VALUES ('dns-only','覆盖','')`, + } { + if _, err := db.DB.ExecContext(t.Context(), statement); err == nil { + t.Fatal("项目保护没有拒绝破坏引用的操作") + } + } + for name, want := range map[string]int{"foreign_keys": foreignKeys, "recursive_triggers": recursive} { + var value int + if err := db.DB.QueryRowContext(t.Context(), "PRAGMA "+name).Scan(&value); err != nil || value != want { + t.Fatal("迁移改变用户连接参数", name, value, err) + } + } + }) + } + } +} + +func TestDNSAccountMigration(t *testing.T) { + for _, driver := range []string{"sqlite", "mysql", "postgres"} { + t.Run(driver, func(t *testing.T) { + var cfg config.DatabaseConfig + switch driver { + case "sqlite": + cfg = sqliteBusinessUpgradeConfig(t) + case "mysql": + cfg = mysqltest.New(t).Database(t) + case "postgres": + cfg = postgrestest.New(t).Database(t) + } + db, err := Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.DB.ExecContext(t.Context(), migrationTableSQL(driver)); err != nil { + t.Fatal(err) + } + for version := 1; version <= 26; version++ { + if err := applyMigration(t.Context(), db.DB, driver, version); err != nil { + t.Fatal(err) + } + } + if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO domains (id,name,certificate_type,sans_json,status,challenge_type) + VALUES ('legacy-dns','legacy.example.com','single','[]','valid','http-01')`); err != nil { + t.Fatal(err) + } + if err := Migrate(t.Context(), db.DB, driver); err != nil { + t.Fatal(err) + } + query := `INSERT INTO dns_accounts (id,name,project_id,owner_user_id,provider,credential_ref,status,verification_state,revision,created_at,updated_at) + VALUES ('kept','保留账户','default','missing-owner','aliyun','secret://dns/kept','disabled','unverified',9,?,?)` + if driver == "postgres" { + query = `INSERT INTO dns_accounts (id,name,project_id,owner_user_id,provider,credential_ref,status,verification_state,revision,created_at,updated_at) + VALUES ('kept','保留账户','default','missing-owner','aliyun','secret://dns/kept','disabled','unverified',9,$1,$2)` + } + if _, err := db.DB.ExecContext(t.Context(), query, time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatal(err) + } + if err := Migrate(t.Context(), db.DB, driver); err != nil { + t.Fatal(err) + } + var status, challenge string + if err := db.DB.QueryRowContext(t.Context(), `SELECT status,challenge_type FROM domains WHERE id='legacy-dns'`).Scan(&status, &challenge); err != nil || status != "valid" || challenge != "http-01" { + t.Fatal("升级改变历史域名", err) + } + var revision int + if err := db.DB.QueryRowContext(t.Context(), `SELECT status,revision FROM dns_accounts WHERE id='kept'`).Scan(&status, &revision); err != nil || status != "disabled" || revision != 9 { + t.Fatal("重入覆盖已有账户", err) + } + if _, err := db.DB.ExecContext(t.Context(), `UPDATE dns_accounts SET project_id='missing' WHERE id='kept'`); err == nil { + t.Fatal("项目外键没有生效") + } + }) + } +} + +func TestMySQLDNSAccountInterruptedMigration(t *testing.T) { + f := startRealMySQL(t) + for _, boundary := range []string{"before_create", "after_create", "after_constraint", "with_record", "bad_column", "bad_index", "bad_foreign_key", "bad_collation"} { + t.Run(boundary, func(t *testing.T) { + db := f.database(t) + mysqlPrepareVersion(t, db.DB, 26) + statements := mysqlDeploymentStatements(t, 27) + if boundary != "before_create" { + mysqlDeploymentExec(t, db.DB, statements[0]) + } + if boundary == "after_constraint" || boundary == "with_record" { + mysqlDeploymentExec(t, db.DB, statements[1]) + } + conflict := true + switch boundary { + case "bad_column": + mysqlDeploymentExec(t, db.DB, "ALTER TABLE dns_accounts MODIFY COLUMN revision INT NULL") + case "bad_index": + mysqlDeploymentExec(t, db.DB, "ALTER TABLE dns_accounts DROP INDEX uq_dns_accounts_credential") + case "bad_foreign_key": + mysqlDeploymentExec(t, db.DB, "ALTER TABLE dns_accounts ADD CONSTRAINT fk_dns_accounts_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE") + case "bad_collation": + mysqlDeploymentExec(t, db.DB, "ALTER TABLE dns_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + default: + conflict = false + } + if boundary == "with_record" { + mysqlDeploymentExec(t, db.DB, `INSERT INTO dns_accounts VALUES ('kept','保留账户','default','owner','aliyun','secret://dns/kept','disabled','unverified',7,NOW(),NOW())`) + } + err := Migrate(t.Context(), db.DB, "mysql") + if (err != nil) != conflict { + t.Fatal("中断恢复或冲突拒绝错误", err) + } + version, err := currentMigrationVersion(t.Context(), db.DB) + if err != nil || (conflict && version != 26) || (!conflict && version != latestMigrationVersion) { + t.Fatal("迁移版本错误", version, err) + } + if boundary == "with_record" { + var revision int + if err := db.DB.QueryRowContext(t.Context(), `SELECT revision FROM dns_accounts WHERE id='kept'`).Scan(&revision); err != nil || revision != 7 { + t.Fatal("中断恢复丢失原有账户", err) + } + } + }) + } +} diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 28944d1..598f8e5 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -27,6 +27,7 @@ import ( //go:embed migrations/sqlite/0024_recovery_events.sql migrations/mysql/0024_recovery_events.sql migrations/postgres/0024_recovery_events.sql //go:embed migrations/sqlite/0025_recovery_reconciliation.sql migrations/mysql/0025_recovery_reconciliation.sql migrations/postgres/0025_recovery_reconciliation.sql //go:embed migrations/sqlite/0026_domain_challenge.sql migrations/mysql/0026_domain_challenge.sql migrations/postgres/0026_domain_challenge.sql +//go:embed migrations/sqlite/0027_dns_accounts.sql migrations/mysql/0027_dns_accounts.sql migrations/postgres/0027_dns_accounts.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -51,7 +52,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 26 +const latestMigrationVersion = 27 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -91,6 +92,9 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return applySQLiteTaskMigration(ctx, db, statements, version) } if driver == "mysql" { + if version == 27 { + return applyMySQLDNSAccountMigration(ctx, db, version, statements) + } if (version >= 15 && version <= 17) || (version >= 20 && version <= 24) { return applyMySQLDeploymentMigration(ctx, db, version, statements) } @@ -171,6 +175,12 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return err } } + if driver == "sqlite" && version == 27 { + if err := installSQLiteDNSAccountConstraints(ctx, tx); err != nil { + _ = tx.Rollback() + return err + } + } migrationVersionInsert := "INSERT INTO schema_migrations (version) VALUES (?)" if driver == "postgres" { migrationVersionInsert = "INSERT INTO schema_migrations (version) VALUES ($1)" @@ -274,6 +284,8 @@ func migrationName(version int) string { return "recovery_reconciliation" case 26: return "domain_challenge" + case 27: + return "dns_accounts" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0027_dns_accounts.sql b/backend/internal/database/migrations/mysql/0027_dns_accounts.sql new file mode 100644 index 0000000..2152195 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0027_dns_accounts.sql @@ -0,0 +1,16 @@ +CREATE TABLE dns_accounts ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + project_id VARCHAR(36) NOT NULL, + owner_user_id VARCHAR(36) NOT NULL, + provider VARCHAR(36) NOT NULL, + credential_ref VARCHAR(255) NOT NULL, + status VARCHAR(16) NOT NULL, + verification_state VARCHAR(16) NOT NULL, + revision INT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uq_dns_accounts_credential (credential_ref), + INDEX idx_dns_accounts_project (project_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +ALTER TABLE dns_accounts ADD CONSTRAINT fk_dns_accounts_project FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT; diff --git a/backend/internal/database/migrations/postgres/0027_dns_accounts.sql b/backend/internal/database/migrations/postgres/0027_dns_accounts.sql new file mode 100644 index 0000000..41553d1 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0027_dns_accounts.sql @@ -0,0 +1,15 @@ +CREATE TABLE dns_accounts ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + owner_user_id TEXT NOT NULL, + provider TEXT NOT NULL, + credential_ref TEXT NOT NULL, + status TEXT NOT NULL, + verification_state TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE UNIQUE INDEX uq_dns_accounts_credential ON dns_accounts (credential_ref); +CREATE INDEX idx_dns_accounts_project ON dns_accounts (project_id, id); diff --git a/backend/internal/database/migrations/sqlite/0027_dns_accounts.sql b/backend/internal/database/migrations/sqlite/0027_dns_accounts.sql new file mode 100644 index 0000000..8ecd520 --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0027_dns_accounts.sql @@ -0,0 +1,15 @@ +CREATE TABLE dns_accounts ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + owner_user_id TEXT NOT NULL, + provider TEXT NOT NULL, + credential_ref TEXT NOT NULL, + status TEXT NOT NULL, + verification_state TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE UNIQUE INDEX uq_dns_accounts_credential ON dns_accounts (credential_ref); +CREATE INDEX idx_dns_accounts_project ON dns_accounts (project_id, id); diff --git a/backend/internal/database/mysql_deployment_migration.go b/backend/internal/database/mysql_deployment_migration.go index 92f0339..cabce41 100644 --- a/backend/internal/database/mysql_deployment_migration.go +++ b/backend/internal/database/mysql_deployment_migration.go @@ -50,9 +50,10 @@ type mysqlDeploymentColumn struct { } type mysqlDeploymentShape struct { - Table string - Columns []mysqlDeploymentColumn - Indexes []string + Table string + Collation string + Columns []mysqlDeploymentColumn + Indexes []string } var ( @@ -70,7 +71,13 @@ func parseMySQLDeploymentShape(statement string) (mysqlDeploymentShape, error) { return shape, fmt.Errorf("部署迁移表定义不完整") } match := mysqlDeploymentTablePattern.FindStringSubmatch(strings.TrimSpace(lines[0])) - if len(match) != 2 || strings.TrimSpace(lines[len(lines)-1]) != ")" { + closing := strings.TrimSpace(lines[len(lines)-1]) + if closing == ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" { + shape.Collation = "utf8mb4_bin" + } else if closing != ")" { + return shape, fmt.Errorf("迁移表结尾定义不受支持") + } + if len(match) != 2 { return shape, fmt.Errorf("部署迁移表定义不受支持") } shape.Table = match[1] @@ -108,7 +115,11 @@ func validateMySQLDeploymentShape(ctx context.Context, db *sql.DB, expected mysq WHERE t.table_schema = DATABASE() AND t.table_name = ?`, expected.Table).Scan(&tableCollation, &schemaCollation); err != nil { return err } - if tableCollation != schemaCollation { + expectedCollation := schemaCollation + if expected.Collation != "" { + expectedCollation = expected.Collation + } + if tableCollation != expectedCollation { return fmt.Errorf("部署迁移表排序规则与数据库默认定义不匹配:%s", expected.Table) } var engine, tableType string diff --git a/backend/internal/database/mysql_deployment_migration_test.go b/backend/internal/database/mysql_deployment_migration_test.go index faaf197..3e6e4de 100644 --- a/backend/internal/database/mysql_deployment_migration_test.go +++ b/backend/internal/database/mysql_deployment_migration_test.go @@ -8,7 +8,7 @@ import ( ) func TestMySQLDeploymentShapeDefinitions(t *testing.T) { - for _, version := range []int{15, 16, 17, 20, 21, 22, 23, 24} { + for _, version := range []int{15, 16, 17, 20, 21, 22, 23, 24, 27} { statements := mysqlDeploymentStatements(t, version) shape, err := parseMySQLDeploymentShape(statements[0]) if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 { diff --git a/backend/internal/database/mysql_dns_account_migration.go b/backend/internal/database/mysql_dns_account_migration.go new file mode 100644 index 0000000..dfcf072 --- /dev/null +++ b/backend/internal/database/mysql_dns_account_migration.go @@ -0,0 +1,69 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// MySQL 的建表和外键各自提交;重入时逐项比对,结构冲突不覆盖历史数据。 +func applyMySQLDNSAccountMigration(ctx context.Context, db *sql.DB, version int, statements []string) error { + if len(statements) != 2 { + return fmt.Errorf("DNS 账户迁移语句不完整") + } + shape, err := parseMySQLDeploymentShape(statements[0]) + if err != nil { + return err + } + if _, err := db.ExecContext(ctx, strings.Replace(statements[0], "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)); err != nil { + return fmt.Errorf("创建 DNS 账户表失败:%w", err) + } + if err := validateMySQLDeploymentShape(ctx, db, shape); err != nil { + return err + } + // 先检查已有外键,避免把同名但不同语义的外键当作迁移成功。 + count, err := validateMySQLDNSAccountForeignKey(ctx, db) + if err != nil { + return err + } + if count == 0 { + if _, err := db.ExecContext(ctx, statements[1]); err != nil { + return fmt.Errorf("创建 DNS 账户项目约束失败:%w", err) + } + } + if count, err = validateMySQLDNSAccountForeignKey(ctx, db); err != nil || count != 1 { + return fmt.Errorf("DNS 账户项目约束校验失败:%v", err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil { + return fmt.Errorf("记录 DNS 账户迁移版本失败:%w", err) + } + return nil +} + +func validateMySQLDNSAccountForeignKey(ctx context.Context, db *sql.DB) (int, error) { + rows, err := db.QueryContext(ctx, `SELECT r.constraint_name, r.delete_rule, r.update_rule, + k.column_name, k.referenced_table_schema, k.referenced_table_name, k.referenced_column_name, + k.ordinal_position, DATABASE() + FROM information_schema.referential_constraints r + JOIN information_schema.key_column_usage k ON k.constraint_schema = r.constraint_schema + AND k.table_name = r.table_name AND k.constraint_name = r.constraint_name + WHERE r.constraint_schema = DATABASE() AND r.table_name = 'dns_accounts'`) + if err != nil { + return 0, err + } + defer rows.Close() + count := 0 + for rows.Next() { + var name, onDelete, onUpdate, column, schema, table, target, currentSchema string + var position int + if err := rows.Scan(&name, &onDelete, &onUpdate, &column, &schema, &table, &target, &position, ¤tSchema); err != nil { + return 0, err + } + count++ + if count != 1 || name != "fk_dns_accounts_project" || onDelete != "RESTRICT" || (onUpdate != "RESTRICT" && onUpdate != "NO ACTION") || column != "project_id" || schema != currentSchema || table != "projects" || target != "id" || position != 1 { + return 0, fmt.Errorf("DNS 账户项目外键与预期不一致,拒绝记录迁移版本") + } + } + return count, rows.Err() +} diff --git a/backend/internal/database/sqlite_dns_account_constraints.go b/backend/internal/database/sqlite_dns_account_constraints.go new file mode 100644 index 0000000..ab87fd5 --- /dev/null +++ b/backend/internal/database/sqlite_dns_account_constraints.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "database/sql" + "fmt" +) + +// 与已有资源约束一致,不依赖连接的外键开关,不修改用户的连接参数。 +func installSQLiteDNSAccountConstraints(ctx context.Context, tx *sql.Tx) error { + statements := []string{ + `CREATE TRIGGER scert_dns_accounts_project_insert BEFORE INSERT ON dns_accounts + WHEN NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id) + BEGIN SELECT RAISE(ABORT, 'DNS 账户所属项目不存在'); END`, + `CREATE TRIGGER scert_dns_accounts_project_update BEFORE UPDATE OF project_id ON dns_accounts + WHEN NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id) + BEGIN SELECT RAISE(ABORT, 'DNS 账户所属项目不存在'); END`, + `CREATE TRIGGER scert_dns_accounts_project_delete BEFORE DELETE ON projects + WHEN EXISTS (SELECT 1 FROM dns_accounts WHERE project_id = OLD.id) + BEGIN SELECT RAISE(ABORT, '项目仍有 DNS 账户,不能删除'); END`, + `CREATE TRIGGER scert_dns_accounts_project_rekey BEFORE UPDATE OF id ON projects + WHEN NEW.id <> OLD.id AND EXISTS (SELECT 1 FROM dns_accounts WHERE project_id = OLD.id) + BEGIN SELECT RAISE(ABORT, '项目仍有 DNS 账户,不能更换标识'); END`, + `CREATE TRIGGER scert_dns_accounts_project_replace BEFORE INSERT ON projects + WHEN EXISTS (SELECT 1 FROM dns_accounts WHERE project_id = NEW.id) + BEGIN SELECT RAISE(ABORT, '项目仍有 DNS 账户,不能替换'); END`, + } + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("安装 DNS 账户项目保护失败:%w", err) + } + } + return nil +} diff --git a/backend/internal/domain/dns_account.go b/backend/internal/domain/dns_account.go new file mode 100644 index 0000000..6492a4c --- /dev/null +++ b/backend/internal/domain/dns_account.go @@ -0,0 +1,58 @@ +package domain + +import ( + "errors" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +const ( + DNSAccountActive = "active" + DNSAccountDisabled = "disabled" + DNSAccountUnverified = "unverified" + DNSAccountVerified = "verified" + DNSAccountVerificationFailed = "failed" +) + +// DNSAccount 只保存账户元数据和秘密引用;序列化时不暴露秘密仓库位置。 +// 归属和服务商在创建后固定,轮换必须产生新引用,版本号用于拒绝旧验证结果。 +type DNSAccount struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"project_id"` + OwnerUserID string `json:"owner_user_id"` + Provider string `json:"provider"` + CredentialRef string `json:"-"` + Status string `json:"status"` + VerificationState string `json:"verification_state"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (a DNSAccount) Validate() error { + if !ValidProjectID(a.ID) || !ValidProjectID(a.ProjectID) || !ValidProjectID(a.OwnerUserID) { + return errors.New("DNS 账户标识、项目或归属用户无效") + } + if !utf8.ValidString(a.Name) || strings.TrimSpace(a.Name) == "" || utf8.RuneCountInString(a.Name) > 128 || strings.ContainsFunc(a.Name, unicode.IsControl) { + return errors.New("DNS 账户名称必须为不含控制字符的 1~128 个字符") + } + if !ValidProjectID(a.Provider) { + return errors.New("DNS 服务商标识无效") + } + if !strings.HasPrefix(a.CredentialRef, "secret://dns/") || len(a.CredentialRef) <= len("secret://dns/") || len(a.CredentialRef) > 255 || strings.ContainsFunc(a.CredentialRef, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }) { + return errors.New("DNS 凭据引用无效") + } + if a.Status != DNSAccountActive && a.Status != DNSAccountDisabled { + return errors.New("DNS 账户状态无效") + } + if a.VerificationState != DNSAccountUnverified && a.VerificationState != DNSAccountVerified && a.VerificationState != DNSAccountVerificationFailed { + return errors.New("DNS 账户验证状态无效") + } + if a.Revision < 1 || a.Revision > 2147483647 { + return errors.New("DNS 账户版本无效") + } + return nil +} diff --git a/backend/internal/domain/dns_account_test.go b/backend/internal/domain/dns_account_test.go new file mode 100644 index 0000000..a8e90e5 --- /dev/null +++ b/backend/internal/domain/dns_account_test.go @@ -0,0 +1,44 @@ +package domain + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestDNSAccountValidationAndSerialization(t *testing.T) { + base := DNSAccount{ID: "dns-one", Name: "生产 DNS", ProjectID: "default", OwnerUserID: "primary-super-admin", + Provider: "aliyun", CredentialRef: "secret://dns/aliyun/example", Status: DNSAccountActive, + VerificationState: DNSAccountUnverified, Revision: 1} + if err := base.Validate(); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*DNSAccount){ + "empty_id": func(a *DNSAccount) { a.ID = "" }, + "bad_project": func(a *DNSAccount) { a.ProjectID = "../other" }, + "missing_owner": func(a *DNSAccount) { a.OwnerUserID = "" }, + "blank_name": func(a *DNSAccount) { a.Name = " " }, + "control_name": func(a *DNSAccount) { a.Name = "账户\n名称" }, + "long_name": func(a *DNSAccount) { a.Name = strings.Repeat("中", 129) }, + "invalid_provider": func(a *DNSAccount) { a.Provider = "other/provider" }, + "wrong_secret_type": func(a *DNSAccount) { a.CredentialRef = "secret://ssh/password" }, + "empty_secret_id": func(a *DNSAccount) { a.CredentialRef = "secret://dns/" }, + "secret_whitespace": func(a *DNSAccount) { a.CredentialRef += "\n" }, + "invalid_state": func(a *DNSAccount) { a.Status = "deleted" }, + "invalid_verification": func(a *DNSAccount) { a.VerificationState = "trusted" }, + "zero_revision": func(a *DNSAccount) { a.Revision = 0 }, + "overflow_revision": func(a *DNSAccount) { a.Revision = 2147483648 }, + } { + t.Run(name, func(t *testing.T) { + item := base + change(&item) + if item.Validate() == nil { + t.Fatal("无效账户未被拒绝") + } + }) + } + payload, err := json.Marshal(base) + if err != nil || strings.Contains(string(payload), "secret://") || strings.Contains(string(payload), "credential_ref") { + t.Fatal("账户 JSON 暴露秘密引用", err) + } +} diff --git a/backend/internal/repository/dns_account_repository.go b/backend/internal/repository/dns_account_repository.go new file mode 100644 index 0000000..4edcb1d --- /dev/null +++ b/backend/internal/repository/dns_account_repository.go @@ -0,0 +1,233 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +var ErrDNSAccountConflict = errors.New("DNS 账户已变更,请刷新后重试") + +// DNSAccountRepository 的公开读取不返回凭据引用;秘密读取只能使用独立的使用权限入口。 +// 删除及运行租约将在域名绑定接入后提供,当前不提供无引用保护的删除方法。 +type DNSAccountRepository interface { + CreateDNSAccount(context.Context, string, domain.DNSAccount) (domain.DNSAccount, error) + ListVisibleDNSAccounts(context.Context, string) ([]domain.DNSAccount, error) + GetVisibleDNSAccount(context.Context, string, string) (domain.DNSAccount, error) + GetDNSAccountForUse(context.Context, string, string, string) (domain.DNSAccount, error) + RenameDNSAccount(context.Context, string, string, int64, string) (domain.DNSAccount, error) + SetDNSAccountEnabled(context.Context, string, string, int64, bool) (domain.DNSAccount, error) + RotateDNSAccountCredential(context.Context, string, string, int64, string) (domain.DNSAccount, error) + RecordDNSAccountVerification(context.Context, string, string, int64, bool) (domain.DNSAccount, error) +} + +var _ DNSAccountRepository = (*SQLProjectMemberRepository)(nil) + +const dnsAccountColumns = `dns_accounts.id, dns_accounts.name, dns_accounts.project_id, + dns_accounts.owner_user_id, dns_accounts.provider, dns_accounts.credential_ref, + dns_accounts.status, dns_accounts.verification_state, dns_accounts.revision, + dns_accounts.created_at, dns_accounts.updated_at` + +func scanDNSAccount(row scanner) (domain.DNSAccount, error) { + var item domain.DNSAccount + err := row.Scan(&item.ID, &item.Name, &item.ProjectID, &item.OwnerUserID, &item.Provider, + &item.CredentialRef, &item.Status, &item.VerificationState, &item.Revision, &item.CreatedAt, &item.UpdatedAt) + return item, err +} + +func publicDNSAccount(item domain.DNSAccount) domain.DNSAccount { + item.CredentialRef = "" + return item +} + +func (r *SQLProjectMemberRepository) CreateDNSAccount(ctx context.Context, actorID string, item domain.DNSAccount) (domain.DNSAccount, error) { + if err := r.ensureDB(); err != nil { + return domain.DNSAccount{}, err + } + // 用户归属、初始验证状态及版本只由服务端生成,不能由请求冒领或伪造验证成功。 + item.OwnerUserID, item.Revision = actorID, 1 + item.Status, item.VerificationState = domain.DNSAccountActive, domain.DNSAccountUnverified + item.CreatedAt = time.Now().UTC() + item.UpdatedAt = item.CreatedAt + if err := item.Validate(); err != nil { + return domain.DNSAccount{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.DNSAccount{}, err + } + defer tx.Rollback() + if err := r.authorizeProjectTransaction(ctx, tx, actorID, item.ProjectID, domain.PermissionDNSManage); err != nil { + return domain.DNSAccount{}, err + } + _, err = tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO dns_accounts + (id, name, project_id, owner_user_id, provider, credential_ref, status, verification_state, revision, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.ProjectID, item.OwnerUserID, + item.Provider, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.CreatedAt, item.UpdatedAt) + if err != nil { + // 驱动错误可能回显唯一键中的凭据引用,不把原始错误传向外部接口。 + return domain.DNSAccount{}, errors.New("保存 DNS 账户失败,请检查账户或凭据引用是否重复") + } + if err := writeDNSAccountAudit(ctx, tx, r.driver, actorID, item, "dns_account.create"); err != nil { + return domain.DNSAccount{}, err + } + if err := tx.Commit(); err != nil { + return domain.DNSAccount{}, err + } + return publicDNSAccount(item), nil +} + +func (r *SQLProjectMemberRepository) ListVisibleDNSAccounts(ctx context.Context, actorID string) ([]domain.DNSAccount, error) { + if err := r.resourceReaderReady(actorID); err != nil { + return nil, err + } + predicate, args := resourceReadPredicate("dns_accounts.project_id", actorID, domain.PermissionDNSRead) + rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE `+predicate+` ORDER BY dns_accounts.name, dns_accounts.id`), args...) + if err != nil { + return nil, fmt.Errorf("读取 DNS 账户列表失败:%w", err) + } + defer rows.Close() + items := make([]domain.DNSAccount, 0) + for rows.Next() { + item, err := scanDNSAccount(rows) + if err != nil { + return nil, err + } + items = append(items, publicDNSAccount(item)) + } + return items, rows.Err() +} + +func (r *SQLProjectMemberRepository) GetVisibleDNSAccount(ctx context.Context, actorID, id string) (domain.DNSAccount, error) { + if err := r.resourceReaderReady(actorID); err != nil { + return domain.DNSAccount{}, err + } + predicate, args := resourceReadPredicate("dns_accounts.project_id", actorID, domain.PermissionDNSRead) + args = append(args, id) + item, err := scanDNSAccount(r.db.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE `+predicate+` AND dns_accounts.id = ?`), args...)) + if errors.Is(err, sql.ErrNoRows) { + return domain.DNSAccount{}, ErrProjectAccessDenied + } + return publicDNSAccount(item), err +} + +// GetDNSAccountForUse 要求调用方传真实域名项目;禁用、未验证或跨项目账户不返回秘密引用。 +// 返回的是检查时快照,签发执行仍须通过后续租约机制持有版本,不能把此读取当作执行锁。 +func (r *SQLProjectMemberRepository) GetDNSAccountForUse(ctx context.Context, actorID, id, projectID string) (domain.DNSAccount, error) { + if err := r.resourceReaderReady(actorID); err != nil { + return domain.DNSAccount{}, err + } + predicate, args := resourceReadPredicate("dns_accounts.project_id", actorID, domain.PermissionDNSCredentialUse) + args = append(args, id, projectID, domain.DNSAccountActive, domain.DNSAccountVerified) + item, err := scanDNSAccount(r.db.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE `+predicate+` + AND dns_accounts.id = ? AND dns_accounts.project_id = ? AND dns_accounts.status = ? AND dns_accounts.verification_state = ?`), args...)) + if errors.Is(err, sql.ErrNoRows) { + return domain.DNSAccount{}, ErrProjectAccessDenied + } + return item, err +} + +func (r *SQLProjectMemberRepository) RenameDNSAccount(ctx context.Context, actorID, id string, revision int64, name string) (domain.DNSAccount, error) { + return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.rename", func(item *domain.DNSAccount) error { + item.Name = name + return nil + }) +} + +func (r *SQLProjectMemberRepository) SetDNSAccountEnabled(ctx context.Context, actorID, id string, revision int64, enabled bool) (domain.DNSAccount, error) { + return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.status", func(item *domain.DNSAccount) error { + item.Status = domain.DNSAccountDisabled + if enabled { + item.Status = domain.DNSAccountActive + } + return nil + }) +} + +func (r *SQLProjectMemberRepository) RotateDNSAccountCredential(ctx context.Context, actorID, id string, revision int64, reference string) (domain.DNSAccount, error) { + return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.rotate", func(item *domain.DNSAccount) error { + if reference == item.CredentialRef { + return errors.New("DNS 凭据轮换必须使用新的秘密引用") + } + item.CredentialRef, item.VerificationState = reference, domain.DNSAccountUnverified + return nil + }) +} + +// 验证调用者必须先完成真实 DNS 验证;此入口只接受开始验证时的版本,不接受客户端自报结果。 +func (r *SQLProjectMemberRepository) RecordDNSAccountVerification(ctx context.Context, actorID, id string, revision int64, success bool) (domain.DNSAccount, error) { + return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.verify", func(item *domain.DNSAccount) error { + if item.Status != domain.DNSAccountActive { + return errors.New("已禁用的 DNS 账户不能完成验证") + } + item.VerificationState = domain.DNSAccountVerificationFailed + if success { + item.VerificationState = domain.DNSAccountVerified + } + return nil + }) +} + +func (r *SQLProjectMemberRepository) changeDNSAccount(ctx context.Context, actorID, id string, revision int64, action string, change func(*domain.DNSAccount) error) (domain.DNSAccount, error) { + if err := r.ensureDB(); err != nil { + return domain.DNSAccount{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.DNSAccount{}, err + } + defer tx.Rollback() + item, err := scanDNSAccount(tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE id = ?`+r.resourceWriteLock()), id)) + if errors.Is(err, sql.ErrNoRows) { + return domain.DNSAccount{}, ErrProjectAccessDenied + } + if err != nil { + return domain.DNSAccount{}, err + } + if err := r.authorizeProjectTransaction(ctx, tx, actorID, item.ProjectID, domain.PermissionDNSManage); err != nil { + return domain.DNSAccount{}, err + } + if item.Revision != revision { + return domain.DNSAccount{}, ErrDNSAccountConflict + } + if err := change(&item); err != nil { + return domain.DNSAccount{}, err + } + item.Revision++ + item.UpdatedAt = time.Now().UTC() + if err := item.Validate(); err != nil { + return domain.DNSAccount{}, err + } + result, err := tx.ExecContext(ctx, Rebind(r.driver, `UPDATE dns_accounts SET name = ?, credential_ref = ?, status = ?, verification_state = ?, revision = ?, updated_at = ? WHERE id = ? AND revision = ?`), + item.Name, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.UpdatedAt, item.ID, revision) + if err != nil { + return domain.DNSAccount{}, errors.New("更新 DNS 账户失败,请检查凭据引用是否重复") + } + if count, err := result.RowsAffected(); err != nil || count != 1 { + return domain.DNSAccount{}, ErrDNSAccountConflict + } + if err := writeDNSAccountAudit(ctx, tx, r.driver, actorID, item, action); err != nil { + return domain.DNSAccount{}, err + } + if err := tx.Commit(); err != nil { + return domain.DNSAccount{}, err + } + return publicDNSAccount(item), nil +} + +func writeDNSAccountAudit(ctx context.Context, tx *sql.Tx, driver, actorID string, item domain.DNSAccount, action string) error { + result, err := tx.ExecContext(ctx, Rebind(driver, `INSERT INTO audit_logs + (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) + VALUES (?, 'dns_account', ?, 'success', 'DNS 账户资料已更新', ?, 'project', ?, '', ?)`), action, item.ID, time.Now().UTC(), item.ProjectID, actorID) + if err != nil { + return errors.New("写入 DNS 账户审计失败,变更已回滚") + } + if count, err := result.RowsAffected(); err != nil || count != 1 { + return errors.New("DNS 账户审计未保存,变更已回滚") + } + return nil +} diff --git a/backend/internal/repository/dns_account_repository_test.go b/backend/internal/repository/dns_account_repository_test.go new file mode 100644 index 0000000..52e6a7f --- /dev/null +++ b/backend/internal/repository/dns_account_repository_test.go @@ -0,0 +1,254 @@ +package repository + +import ( + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/postgrestest" +) + +func TestDNSAccountRepository(t *testing.T) { + for _, driver := range []string{"sqlite", "mysql", "postgres"} { + t.Run(driver, func(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "dns-accounts.db") + if driver == "mysql" { + cfg = mysqltest.New(t).Database(t) + } + if driver == "postgres" { + cfg = postgrestest.New(t).Database(t) + } + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := database.Migrate(t.Context(), db.DB, driver); err != nil { + t.Fatal(err) + } + checkDNSAccountRepository(t, db.DB, driver) + }) + } +} + +func checkDNSAccountRepository(t *testing.T, db *sql.DB, driver string) { + t.Helper() + repo := NewSQLProjectMemberRepository(db, driver) + for _, actor := range []struct{ id, role string }{ + {"root", "super_admin"}, {"manager", "user"}, {"dns", "user"}, {"reader", "user"}, + {"cert", "user"}, {"audit", "user"}, {"outsider", "admin"}, + } { + if _, err := db.ExecContext(t.Context(), Rebind(driver, `INSERT INTO users (id,username,password_hash,role,status) + VALUES (?,?,'test-only-no-login',?,'active')`), actor.id, actor.id, actor.role); err != nil { + t.Fatal(err) + } + } + for _, project := range []string{"dns-a", "dns-b"} { + if err := NewSQLProjectRepository(db, driver).CreateProject(t.Context(), domain.Project{ID: project, Name: "DNS 测试项目"}); err != nil { + t.Fatal(err) + } + } + for actor, role := range map[string]domain.ProjectRole{ + "manager": domain.ProjectRoleAdmin, "dns": domain.ProjectRoleDNSAdmin, "reader": domain.ProjectRoleReadOnly, + "cert": domain.ProjectRoleCertificateAdmin, "audit": domain.ProjectRoleAuditViewer, + } { + if err := repo.GrantProjectRole(t.Context(), "root", "dns-a", actor, role); err != nil { + t.Fatal(err) + } + } + if err := repo.GrantProjectRole(t.Context(), "root", "dns-a", "manager", domain.ProjectRoleReadOnly); err != nil { + t.Fatal(err) + } + makeAccount := func(id, project string) domain.DNSAccount { + return domain.DNSAccount{ID: id, Name: "账户" + id, ProjectID: project, Provider: "aliyun", CredentialRef: "secret://dns/aliyun/" + id, + OwnerUserID: "forged-owner", VerificationState: domain.DNSAccountVerified, Revision: 999} + } + a, err := repo.CreateDNSAccount(t.Context(), "dns", makeAccount("account-a", "dns-a")) + if err != nil || a.OwnerUserID != "dns" || a.Revision != 1 || a.VerificationState != domain.DNSAccountUnverified || a.CredentialRef != "" { + t.Fatal("创建未固定真实归属或初始状态", a, err) + } + if _, err := repo.CreateDNSAccount(t.Context(), "root", makeAccount("account-b", "dns-b")); err != nil { + t.Fatal(err) + } + for _, actor := range []string{"reader", "cert", "audit", "outsider", "missing"} { + if _, err := repo.CreateDNSAccount(t.Context(), actor, makeAccount("denied", "dns-a")); err == nil { + t.Fatalf("越权创建未拒绝:%s", actor) + } + } + for actor, count := range map[string]int{"root": 2, "manager": 1, "dns": 1, "reader": 1, "cert": 0, "audit": 0, "outsider": 0, "missing": 0} { + items, err := repo.ListVisibleDNSAccounts(t.Context(), actor) + if err != nil || len(items) != count { + t.Fatalf("账户可见范围错误:%s %d %v", actor, len(items), err) + } + for _, item := range items { + if item.CredentialRef != "" { + t.Fatal("列表返回凭据引用") + } + } + } + for _, id := range []string{"account-b", "missing"} { + if _, err := repo.GetVisibleDNSAccount(t.Context(), "dns", id); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("跨项目和不存在账户应同样拒绝", err) + } + } + if _, err := repo.GetDNSAccountForUse(t.Context(), "dns", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("未验证账户可被使用", err) + } + a, err = repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, a.Revision, true) + if err != nil || a.Revision != 2 { + t.Fatal(err) + } + used, err := repo.GetDNSAccountForUse(t.Context(), "dns", a.ID, "dns-a") + if err != nil || used.CredentialRef != "secret://dns/aliyun/account-a" { + t.Fatal("已授权使用未取得正确引用", err) + } + if _, err := repo.GetDNSAccountForUse(t.Context(), "root", a.ID, "dns-b"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("超级管理员也不得跨项目使用", err) + } + if _, err := repo.GetDNSAccountForUse(t.Context(), "reader", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("只读角色取得凭据引用", err) + } + if _, err := repo.RenameDNSAccount(t.Context(), "outsider", a.ID, a.Revision, "越权"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal(err) + } + old := a.Revision + a, err = repo.RotateDNSAccountCredential(t.Context(), "dns", a.ID, old, "secret://dns/aliyun/rotated") + if err != nil || a.VerificationState != domain.DNSAccountUnverified { + t.Fatal("轮换未使验证失效", err) + } + if _, err := repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, old, true); !errors.Is(err, ErrDNSAccountConflict) { + t.Fatal("旧验证覆盖轮换结果", err) + } + if _, err := repo.RotateDNSAccountCredential(t.Context(), "dns", a.ID, a.Revision, "secret://dns/aliyun/rotated"); err == nil { + t.Fatal("复用旧引用轮换未被拒绝") + } + if _, err := repo.RotateDNSAccountCredential(t.Context(), "dns", a.ID, a.Revision, "secret://dns/aliyun/account-b"); err == nil || strings.Contains(err.Error(), "secret://") { + t.Fatal("重复引用未拒绝或泄露秘密位置", err) + } + old = a.Revision + a, err = repo.SetDNSAccountEnabled(t.Context(), "manager", a.ID, old, false) + if err != nil || a.Status != domain.DNSAccountDisabled { + t.Fatal(err) + } + if _, err := repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, old, true); !errors.Is(err, ErrDNSAccountConflict) { + t.Fatal("旧验证覆盖禁用状态", err) + } + if _, err := repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, a.Revision, true); err == nil { + t.Fatal("禁用账户允许完成验证") + } + if _, err := repo.GetDNSAccountForUse(t.Context(), "root", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("禁用账户允许使用", err) + } + a, err = repo.SetDNSAccountEnabled(t.Context(), "manager", a.ID, a.Revision, true) + if err != nil { + t.Fatal(err) + } + a, err = repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, a.Revision, false) + if err != nil || a.VerificationState != domain.DNSAccountVerificationFailed { + t.Fatal(err) + } + if _, err := repo.GetDNSAccountForUse(t.Context(), "dns", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("验证失败账户可使用", err) + } + a, err = repo.RenameDNSAccount(t.Context(), "manager", a.ID, a.Revision, "轮换后的账户") + if err != nil || a.OwnerUserID != "dns" || a.ProjectID != "dns-a" || a.Provider != "aliyun" { + t.Fatal("修改时归属发生变化", err) + } + if err := repo.RevokeProjectRole(t.Context(), "root", "dns-a", "dns", domain.ProjectRoleDNSAdmin); err != nil { + t.Fatal(err) + } + if _, err := repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, a.Revision, true); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("撤权后验证结果仍可写入", err) + } + if _, err := db.ExecContext(t.Context(), `UPDATE users SET status='disabled' WHERE id='manager'`); err != nil { + t.Fatal(err) + } + items, err := repo.ListVisibleDNSAccounts(t.Context(), "manager") + if err != nil || len(items) != 0 { + t.Fatal("停用账户仍可读取", err) + } + if _, err := repo.RenameDNSAccount(t.Context(), "manager", a.ID, a.Revision, "已停用"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("停用账户仍可修改", err) + } + var count int + if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_logs WHERE resource_type='dns_account' AND (message LIKE '%secret://%' OR message LIKE '%rotated%')`).Scan(&count); err != nil || count != 0 { + t.Fatal("审计包含秘密引用", err) + } + if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_logs WHERE resource_type='dns_account' AND resource_id='account-a'`).Scan(&count); err != nil || int64(count) != a.Revision { + t.Fatal("成功修订与事务审计数量不一致", count, a.Revision, err) + } + if _, err := db.ExecContext(t.Context(), `DELETE FROM projects WHERE id='dns-b'`); err == nil { + t.Fatal("删除项目产生孤立 DNS 账户") + } + concurrent, err := repo.CreateDNSAccount(t.Context(), "root", makeAccount("concurrent", "dns-a")) + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + <-start + _, err := repo.RenameDNSAccount(t.Context(), "root", concurrent.ID, concurrent.Revision, "并发改名") + results <- err + }() + go func() { + <-start + _, err := repo.SetDNSAccountEnabled(t.Context(), "root", concurrent.ID, concurrent.Revision, false) + results <- err + }() + close(start) + successes := 0 + for range 2 { + err := <-results + if err == nil { + successes++ + continue + } + if !errors.Is(err, ErrDNSAccountConflict) && !(driver == "sqlite" && strings.Contains(strings.ToLower(err.Error()), "locked")) { + t.Fatal("并发修改出现非预期错误", err) + } + } + stored, err := repo.GetVisibleDNSAccount(t.Context(), "root", concurrent.ID) + if err != nil || successes != 1 || stored.Revision != 2 { + t.Fatal("并发更新没有唯一胜出版本", successes, stored.Revision, err) + } + var auditCount int + if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_logs WHERE resource_type='dns_account' AND resource_id='concurrent'`).Scan(&auditCount); err != nil || auditCount != 2 { + t.Fatal("并发失败留下多余审计", err) + } + var rejection []string + switch driver { + case "sqlite": + rejection = []string{`CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs WHEN NEW.resource_type='dns_account' BEGIN SELECT RAISE(ABORT, 'blocked'); END`} + case "mysql": + rejection = []string{`CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='blocked'`} + case "postgres": + rejection = []string{`CREATE FUNCTION reject_dns_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'blocked'; END $$`, + `CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION reject_dns_audit()`} + } + for _, statement := range rejection { + if _, err := db.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + if _, err := repo.RenameDNSAccount(t.Context(), "root", a.ID, a.Revision, "不应写入"); err == nil { + t.Fatal("审计失败未阻止变更") + } + after, err := repo.GetVisibleDNSAccount(t.Context(), "root", a.ID) + if err != nil || after.Name != a.Name || after.Revision != a.Revision { + t.Fatal("审计失败未回滚", err) + } + if _, err := repo.CreateDNSAccount(t.Context(), "root", makeAccount("failed-audit", "dns-a")); err == nil { + t.Fatal("审计失败仍创建账户") + } + if _, err := repo.GetVisibleDNSAccount(t.Context(), "root", "failed-audit"); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("创建失败留下账户", err) + } +} diff --git a/backend/internal/repository/project_resources.go b/backend/internal/repository/project_resources.go index 7803487..94d0d32 100644 --- a/backend/internal/repository/project_resources.go +++ b/backend/internal/repository/project_resources.go @@ -31,10 +31,10 @@ const visibleDomainSelect = `SELECT domains.id, domains.name, domains.certificat // 列名只接受内部固定值,角色能力复用领域模型;所有身份和角色值均使用绑定参数。 // EXISTS 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。 func resourceReadPredicate(projectColumn, actorID string, permission domain.ProjectPermission) (string, []any) { - if projectColumn != "domains.project_id" && projectColumn != "servers.project_id" { - return "1 = 0", nil - } - if permission != domain.PermissionCertificateRead && permission != domain.PermissionServerRead { + valid := projectColumn == "domains.project_id" && permission == domain.PermissionCertificateRead || + projectColumn == "servers.project_id" && permission == domain.PermissionServerRead || + projectColumn == "dns_accounts.project_id" && (permission == domain.PermissionDNSRead || permission == domain.PermissionDNSCredentialUse) + if !valid { return "1 = 0", nil } roles := []domain.ProjectRole{ diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md new file mode 100644 index 0000000..3944423 --- /dev/null +++ b/doc/DNS多账户整改任务清单.md @@ -0,0 +1,39 @@ +# DNS 多账户整改任务清单 + +## 需求与边界(2026年10月4日) + +依据《Scert整改合并优化清单》和《核心能力任务规格》P1-03,以及用户已确认的 DNS 账户归属要求执行;不是新增需求。范围仅 Linux 与 Web,第一台部署业务,第二台只验收证书推送。公共域名切换和邮件授权尚待确认,不阻断本项开发。 + +- 独立保存多个 DNS 账户,显示项目和用户归属,不把固定系统账户列表视为完成。 +- 支持账户创建、验证、禁用、轮换和安全删除;凭据保存在现有秘密仓库,不在接口、日志或审计输出明文。 +- 域名显式选择账户;签发、自动续期和重试使用同一绑定及实时权限,禁止跨项目使用。 +- 兼容旧系统账户,处理域名、任务和旧凭据引用,不在迁移时擅自删除历史配置或密钥。 +- 处理账户变更与签发并发,验证结果只能更新对应凭据版本;禁用不能被旧验证响应覆盖。 +- 页面保持一页可用;覆盖 SQLite、MySQL、PostgreSQL 迁移、权限与竞态,再做第一台正式部署和实机验收。 + +## 执行状态 + +- [✓] 需求与现有代码核对:当前只有配置文件中的单账户,没有独立账户表和域名账户绑定。 +- [✓] 修改前检查点已提交推送:`26b8c3c`。保留未跟踪文件 `=`,不提交、不删除。 +- [x] 账户基础模型和迁移二十七:独立账户元数据、归属、状态、秘密引用和版本号;三库初轮专项通过,等待最终回归复审。 +- [x] 基础仓储:实时项目权限、事务审计、版本冲突、轮换后验证失效和禁止公开返回秘密引用;未提供无引用保护的删除方法。 +- [~] 三库迁移和仓储自动化:首轮通过,正在补充三轮竞态、三库审计失败回滚及 SQLite 四种开关组合。 +- [ ] 旧系统账户导入及幂等恢复,保护配置与历史凭据引用。 +- [ ] 域名绑定、引用保护、安全删除,以及运行中任务的凭据租约和清理。 +- [ ] 正式签发、续期、重试的账户解析与执行前权限复核。 +- [ ] 账户验证、增改、启停、轮换接口及无缓存和敏感字段隔离。 +- [ ] DNS 账户页、域名配置页及前端回归。 +- [ ] 第一台 Linux 全量、竞态、三库实测与正式产物升级。 +- [ ] 公共域名真实浏览器和授权 DNS/公共 CA 验收。 + +## 完成标准 + +只完成数据模型或管理接口不能关闭 P1-03。每阶段记录真实测试结果;外部服务未验证、跳过或仅隔离验证的项目维持未完成。CNAME 委派保留在总清单,不因本项账户开发自动关闭。 + +## 首轮验证记录(运行环境 UTC+8,2026年10月4日) + +- 本地首轮专项发现 SQLite 默认外键开关关闭时引用保护无效;补项目插入、更新、删除、更换标识及替换触发器,不全局修改用户连接参数。首批失败真实保留,不把原代码计为通过。 +- 本地领域、数据库和仓储包全量通过;本地未配置的 MySQL/PostgreSQL 实库用例跳过,不计实库证据。新增完整用例后三轮专项和 vet 通过。 +- 第一台首轮任务 `scert-dns-accounts-20261004-0425.service` 已 inactive、success、退出码零:三库迁移、账户仓储、MySQL 八种中断/冲突和 vet 通过。日志为 `/var/tmp/scert-dns-accounts-20261004-0425/dns-accounts.log`。 +- 增量竞态任务 `scert-dns-accounts-race-20261004-0430.service` 正在运行;结果以真实终态为准,尚未标记通过。 +- 当前 HTTP 路由、系统旧账户、域名签发配置和线上正式版本均未切换,生产数据及第二台未操作。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 1c67096..fadd154 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,14 @@ # Scert 开发任务清单 +## 2026年10月4日04:32 DNS 多账户开发 + +- [x] 独立账户数据模型、三库迁移二十七、带实时项目授权的基础操作仓储已编写,初轮实库通过。 +- [x] 公开读取不返回秘密引用;创建固定真实归属,禁用、轮换、验证和改名均递增版本并同事务写审计,拒绝旧验证结果覆盖新状态。 +- [~] MySQL 中断恢复八场景首轮通过,SQLite 开关独立保护已补齐,三库并发及审计失败回滚正在做增量竞态回归。 +- [ ] 旧配置导入、域名绑定、引用/运行任务保护、正式签发续期、接口与前端仍待开发。当前不是可上线的完整 DNS 多账户功能。 + +本批详细状态见《DNS多账户整改任务清单》,仅第一台执行隔离验证,没有操作第二台或清理生产数据库。 + ## 2026年10月4日03:55 系统设置隔离开发与部署进度 - [✓] 全局系统设置 GET/PUT 超级管理员限制、私有无缓存响应、前端会话先行和并发旧响应拒绝已实现;普通角色不请求管理员目录。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 4307ead..8c3b933 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,13 @@ # Scert 整改任务执行清单 +## 2026年10月4日04:32 DNS 多账户数据层 + +- [✓] 基于 P1-03 已确认需求建立《DNS多账户整改任务清单》,修改前检查点 `26b8c3c` 已提交推送。 +- [x] 独立账户模型、迁移二十七和基础权限仓储已实现,含归属、启停、轮换后验证失效、版本冲突与事务审计。 +- [✓] 第一台首轮三库专项及 vet 结束成功;本地首轮 SQLite 外键关闭缺陷已补触发器修复,未掩盖失败。 +- [~] 增量三轮竞态和更完整三库事务回归运行中,具体执行单元与日志见专项清单及测试记录。 +- [ ] 旧配置迁移、域名绑定、运行租约、安全删除、签发续期和 Web 操作仍待接入;P1-03 和生产验收不关闭。 + ## 2026年10月4日03:55 系统设置隔离任务 - [✓] 需求确认:全局系统设置仅超级管理员读写;普通管理员仍可使用项目工作台,不能因设置接口被拒绝而整页失败。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 98f65ab..77de048 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,11 @@ # Scert 整改、合并与优化清单 +## 2026年10月4日04:06 DNS 多账户生命周期开发 + +- [~] 正在按《DNS多账户整改任务清单》补独立账户模型、三库迁移及带实时权限的生命周期仓储;修改前检查点 `26b8c3c` 已推送。 +- [ ] 旧系统账户迁移、域名绑定、实际签发与续期、账户操作接口及页面尚待接入,不以数据层代替完整功能。 +- [!] 公共域名仍指第二台,尚未改解析;系统发信、公共浏览器角色矩阵和总体验收未完成。继续第一台相关开发,不重复第二台主程序测试。 + ## 2026年10月4日03:55 系统设置隔离与第一台升级 本节按运行环境 UTC+8 记录,优先于历史状态。本批检查点 `8b26cbf`、实现 `3ff59dd` 和前端兼容增量 `c5774f3` 已提交推送;第一台已通过官方安装器升级至干净 `c5774f3`,第二台未操作。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index 6cd590a..b12f83f 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -41,6 +41,8 @@ ## P1-03 完善 ACME 和 DNS 管理 +2026年10月4日04:32 增量:独立 DNS 账户模型、三库迁移二十七和实时授权基础仓储已经实现,首轮第一台三库专项与 vet 通过;SQLite 外键关闭时的保护缺口已补触发器修复。三轮竞态、三库审计回滚等增量回归运行中。仍未接入旧系统账户导入、域名绑定、任务租约、正式签发续期、账户接口和页面,不能把本批基础数据层视为完整多账户生命周期。细项见《DNS多账户整改任务清单》。 + 2026年10月4日03:14 更新:HTTP-01 已接入正式签发、订单隔离路由、域名验证方式与迁移二十六;实际签发快照授权已补强。第一台 Linux 全量、vet、专项竞态、真实 Nginx 十项和三库迁移专项通过,干净 `9c26db0` 官方包已升级第一台并复核运行摘要。实际 lego 客户端完整 ACME 协议与公共域名签发仍待验收;system-dns 单账户列表不能代表独立多账户生命周期完成,完整账户及 CNAME 委派仍有开发缺口。保留六阶段未完成状态,内部就绪不替代公共入口、首次 Web 与全角色矩阵。 - 任务编号:P1-03;任务名称:完善 ACME 和 DNS 管理;优先级:P1。