保存 DNS 多账户数据层与实库故障夹具修复前检查点
基础模型和迁移已完成;增量竞态暴露 MySQL 审计故障注入权限问题,后续修复,不代表完整功能或验收通过。
This commit is contained in:
parent
26b8c3c5c0
commit
02e039984a
164
backend/internal/database/dns_account_migration_test.go
Normal file
164
backend/internal/database/dns_account_migration_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
69
backend/internal/database/mysql_dns_account_migration.go
Normal file
69
backend/internal/database/mysql_dns_account_migration.go
Normal file
@ -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()
|
||||
}
|
||||
34
backend/internal/database/sqlite_dns_account_constraints.go
Normal file
34
backend/internal/database/sqlite_dns_account_constraints.go
Normal file
@ -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
|
||||
}
|
||||
58
backend/internal/domain/dns_account.go
Normal file
58
backend/internal/domain/dns_account.go
Normal file
@ -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
|
||||
}
|
||||
44
backend/internal/domain/dns_account_test.go
Normal file
44
backend/internal/domain/dns_account_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
233
backend/internal/repository/dns_account_repository.go
Normal file
233
backend/internal/repository/dns_account_repository.go
Normal file
@ -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
|
||||
}
|
||||
254
backend/internal/repository/dns_account_repository_test.go
Normal file
254
backend/internal/repository/dns_account_repository_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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{
|
||||
|
||||
39
doc/DNS多账户整改任务清单.md
Normal file
39
doc/DNS多账户整改任务清单.md
Normal file
@ -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 路由、系统旧账户、域名签发配置和线上正式版本均未切换,生产数据及第二台未操作。
|
||||
@ -1,5 +1,14 @@
|
||||
# Scert 开发任务清单
|
||||
|
||||
## 2026年10月4日04:32 DNS 多账户开发
|
||||
|
||||
- [x] 独立账户数据模型、三库迁移二十七、带实时项目授权的基础操作仓储已编写,初轮实库通过。
|
||||
- [x] 公开读取不返回秘密引用;创建固定真实归属,禁用、轮换、验证和改名均递增版本并同事务写审计,拒绝旧验证结果覆盖新状态。
|
||||
- [~] MySQL 中断恢复八场景首轮通过,SQLite 开关独立保护已补齐,三库并发及审计失败回滚正在做增量竞态回归。
|
||||
- [ ] 旧配置导入、域名绑定、引用/运行任务保护、正式签发续期、接口与前端仍待开发。当前不是可上线的完整 DNS 多账户功能。
|
||||
|
||||
本批详细状态见《DNS多账户整改任务清单》,仅第一台执行隔离验证,没有操作第二台或清理生产数据库。
|
||||
|
||||
## 2026年10月4日03:55 系统设置隔离开发与部署进度
|
||||
|
||||
- [✓] 全局系统设置 GET/PUT 超级管理员限制、私有无缓存响应、前端会话先行和并发旧响应拒绝已实现;普通角色不请求管理员目录。
|
||||
|
||||
@ -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 系统设置隔离任务
|
||||
|
||||
- [✓] 需求确认:全局系统设置仅超级管理员读写;普通管理员仍可使用项目工作台,不能因设置接口被拒绝而整页失败。
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 2026年10月4日04:06 DNS 多账户生命周期开发
|
||||
|
||||
- [~] 正在按《DNS多账户整改任务清单》补独立账户模型、三库迁移及带实时权限的生命周期仓储;修改前检查点 `26b8c3c` 已推送。
|
||||
- [ ] 旧系统账户迁移、域名绑定、实际签发与续期、账户操作接口及页面尚待接入,不以数据层代替完整功能。
|
||||
- [!] 公共域名仍指第二台,尚未改解析;系统发信、公共浏览器角色矩阵和总体验收未完成。继续第一台相关开发,不重复第二台主程序测试。
|
||||
|
||||
## 2026年10月4日03:55 系统设置隔离与第一台升级
|
||||
|
||||
本节按运行环境 UTC+8 记录,优先于历史状态。本批检查点 `8b26cbf`、实现 `3ff59dd` 和前端兼容增量 `c5774f3` 已提交推送;第一台已通过官方安装器升级至干净 `c5774f3`,第二台未操作。
|
||||
|
||||
@ -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。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user