Scert/backend/internal/repository/dns_account_repository.go
xuans 02e039984a 保存 DNS 多账户数据层与实库故障夹具修复前检查点
基础模型和迁移已完成;增量竞态暴露 MySQL 审计故障注入权限问题,后续修复,不代表完整功能或验收通过。
2026-10-04 04:42:39 +08:00

234 lines
11 KiB
Go

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
}