完善任务审计范围快照与身份关联并同步整改进度

新增三库第13号迁移和历史范围回填,保留已删除资源的任务与审计。补充仓储、调用链、身份来源及迁移失败重试回归,同步任务清单、支持矩阵和运维边界。项目成员授权与生产验收仍待完成。
This commit is contained in:
xuans 2026-09-28 11:48:27 +08:00
parent 5237e8d810
commit 0ccdb14b26
34 changed files with 989 additions and 48 deletions

View File

@ -53,6 +53,7 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s
finished := time.Now().UTC()
status, message := certificateTaskOutcome(resultErr)
auditErr := w.audit.CreateAuditLog(finishCtx, repository.AuditLog{
TaskID: task.ID,
Action: taskType, ResourceType: "domain", ResourceID: id,
Status: status, Message: message,
})
@ -67,6 +68,7 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s
}
}()
if err := w.audit.CreateAuditLog(ctx, repository.AuditLog{
TaskID: task.ID,
Action: taskType, ResourceType: "domain", ResourceID: id,
Status: "running", Message: "开始执行证书工作流",
}); err != nil {

View File

@ -40,6 +40,9 @@ func assertCertificateTask(t *testing.T, a *App, status string, statuses ...stri
t.Fatalf("任务数量错误:%+v %v", items, err)
}
item := items[0]
if item.ScopeType != repository.ScopeProject || item.ProjectID != domain.DefaultProjectID || item.OperatorID != "" {
t.Fatalf("后台任务范围错误或伪造操作者:%+v", item)
}
if item.Status != status || item.StartedAt == nil || item.FinishedAt == nil {
t.Fatalf("任务未正确收尾:%+v", item)
}
@ -59,6 +62,9 @@ func assertCertificateTask(t *testing.T, a *App, status string, statuses ...stri
if err := a.Database.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs WHERE action = ? AND resource_id = ? AND status = ?", item.TaskType, item.DomainID, status).Scan(&count); err != nil || count != 1 {
t.Fatalf("审计记录缺失:%d %v", count, err)
}
if err := a.Database.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs WHERE task_id = ? AND scope_type = 'project' AND project_id = ? AND operator_id = '' AND status IN ('running', ?)", item.ID, item.ProjectID, status).Scan(&count); err != nil || count != 2 {
t.Fatalf("后台任务开始与收尾审计关联缺失:%d,%v", count, err)
}
return item
}

View File

@ -14,7 +14,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
const managementResourceID = "management-certificate"
const managementResourceID = repository.ManagementResourceID
type managementActivator interface {
Ready(context.Context, httpapi.ManagementCertificateState) error
@ -106,12 +106,14 @@ func (m *managementRenewal) renew(ctx context.Context, state httpapi.ManagementC
finished := time.Now().UTC()
taskErr := m.tasks.UpdateTask(finishCtx, task.ID, status, stage, message, &started, &finished)
auditErr := m.audit.CreateAuditLog(finishCtx, repository.AuditLog{
TaskID: task.ID,
Action: "management_renewal", ResourceType: "management_certificate", ResourceID: managementResourceID,
Status: status, Message: state.Hostname + ":" + message,
})
resultErr = errors.Join(resultErr, taskErr, auditErr)
}()
if err := m.audit.CreateAuditLog(ctx, repository.AuditLog{
TaskID: task.ID,
Action: "management_renewal", ResourceType: "management_certificate", ResourceID: managementResourceID,
Status: "running", Message: state.Hostname + ":开始管理证书续期",
}); err != nil {

View File

@ -94,6 +94,9 @@ func (f *managementFixture) assertTask(t *testing.T, status string, attempts int
t.Fatalf("管理任务未独立持久化:%+v %v", items, err)
}
item := items[0]
if item.ScopeType != repository.ScopeSystem || item.ProjectID != "" || item.OperatorID != "" {
t.Fatalf("管理任务没有保持系统范围:%+v", item)
}
if item.Status != status || item.FinishedAt == nil || item.TaskType != "management_renewal" {
t.Fatalf("管理任务终态异常:%+v", item)
}
@ -110,6 +113,9 @@ func (f *managementFixture) assertTask(t *testing.T, status string, attempts int
if err := f.app.Database.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs WHERE action = ? AND status = ?", "management_renewal", status).Scan(&count); err != nil || count != 1 {
t.Fatalf("管理续期审计缺失:%d %v", count, err)
}
if err := f.app.Database.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs WHERE action = 'management_renewal' AND (task_id <> ? OR scope_type <> 'system' OR project_id <> '' OR operator_id <> '')", item.ID).Scan(&count); err != nil || count != 0 {
t.Fatalf("管理续期审计范围或关联错误:%d,%v", count, err)
}
return item
}
@ -119,7 +125,11 @@ func TestManagementRenewalPersistsWithoutBusinessDomain(t *testing.T) {
if err != nil || report.Checked != 1 || report.Selected != 1 || report.Succeeded != 1 || f.issues != 1 || f.activator.calls != 1 {
t.Fatalf("管理续期未完成:%+v %v", report, err)
}
f.assertTask(t, "success", 1)
task := f.assertTask(t, "success", 1)
var count int
if err := f.app.Database.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs WHERE task_id = ? AND status IN ('running', 'success')", task.ID).Scan(&count); err != nil || count != 2 {
t.Fatalf("正常管理续期缺少开始或收尾审计:%d,%v", count, err)
}
items, err := f.app.Repository.List(t.Context())
if err != nil || len(items) != 0 {
t.Fatalf("管理续期不得创建业务域名:%+v %v", items, err)

View File

@ -241,7 +241,7 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) {
}
defer db.Close()
var version, bindings int
if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 12 {
if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 13 {
t.Fatalf("正式应用入口未完成历史升级:%d,%v", version, err)
}
project, err := repository.NewSQLProjectRepository(db.DB).GetProject(t.Context(), domain.DefaultProjectID)
@ -249,7 +249,7 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) {
t.Fatalf("正式应用入口未保留默认项目:%+v,%v", project, err)
}
task, err := repository.NewSQLTaskRepository(db.DB).GetTask(t.Context(), "task")
if err != nil || task.Message != "历史任务" || task.DomainID != "domain" || task.Status != "success" {
if err != nil || task.Message != "历史任务" || task.DomainID != "domain" || task.Status != "success" || task.ScopeType != repository.ScopeProject || task.ProjectID != domain.DefaultProjectID {
t.Fatalf("历史任务不可用:%v", err)
}
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM domain_servers WHERE domain_id = 'domain' AND server_id = 'server'").Scan(&bindings); err != nil || bindings != 1 {

View File

@ -0,0 +1,42 @@
package database
import (
"context"
"database/sql"
"fmt"
)
// 历史范围是不可级联删除的快照,不强行给已删除资源补默认项目或操作者。
// MySQL 的部分 DDL 重试只回填仍未知的记录,不覆盖已经保存的范围。
func backfillHistoryScope(ctx context.Context, db interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}) error {
statements := []string{
`UPDATE tasks SET scope_type = 'system' WHERE scope_type = 'unknown'
AND task_type = 'management_renewal' AND domain_id = 'management-certificate'`,
`UPDATE tasks SET scope_type = 'project',
project_id = (SELECT d.project_id FROM domains d WHERE d.id = tasks.domain_id)
WHERE scope_type = 'unknown' AND EXISTS (SELECT 1 FROM domains d WHERE d.id = tasks.domain_id)`,
`UPDATE audit_logs SET scope_type = 'system' WHERE scope_type = 'unknown'
AND (resource_type IN ('auth', 'user', 'super_admin')
OR (resource_type = 'management_certificate' AND resource_id = 'management-certificate'))`,
`UPDATE audit_logs SET task_id = resource_id WHERE resource_type = 'task' AND task_id = ''`,
`UPDATE audit_logs SET scope_type = (SELECT t.scope_type FROM tasks t WHERE t.id = audit_logs.task_id),
project_id = (SELECT t.project_id FROM tasks t WHERE t.id = audit_logs.task_id)
WHERE scope_type = 'unknown' AND task_id <> '' AND EXISTS (SELECT 1 FROM tasks t WHERE t.id = audit_logs.task_id)`,
`UPDATE audit_logs SET scope_type = 'project',
project_id = (SELECT d.project_id FROM domains d WHERE d.id = audit_logs.resource_id)
WHERE scope_type = 'unknown' AND resource_type IN ('domain', 'certificate')
AND EXISTS (SELECT 1 FROM domains d WHERE d.id = audit_logs.resource_id)`,
`UPDATE audit_logs SET scope_type = 'project',
project_id = (SELECT s.project_id FROM servers s WHERE s.id = audit_logs.resource_id)
WHERE scope_type = 'unknown' AND resource_type = 'server'
AND EXISTS (SELECT 1 FROM servers s WHERE s.id = audit_logs.resource_id)`,
}
for _, statement := range statements {
if _, err := db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("回填任务审计历史范围失败:%w", err)
}
}
return nil
}

View File

@ -0,0 +1,223 @@
package database
import (
"database/sql"
"strings"
"testing"
)
func seedHistoryScope(t *testing.T, db *sql.DB, driver string) {
t.Helper()
if _, err := db.ExecContext(t.Context(), migrationTableSQL(driver)); err != nil {
t.Fatal(err)
}
for version := 1; version <= 12; version++ {
if err := applyMigration(t.Context(), db, driver, version); err != nil {
t.Fatal(err)
}
}
for _, statement := range []string{
`INSERT INTO domains (id, name, certificate_type, sans_json, status) VALUES ('domain', 'history.example.test', 'single', '[]', 'pending')`,
`INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json, validation_command_json, ssh_key_error) VALUES ('server', '历史服务器', '192.0.2.1', 22, 'deploy', 'secret://test', 'test-only', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '[]', '[]', '')`,
`INSERT INTO tasks (id, domain_id, task_type, status, message, operator_id) VALUES
('business', 'domain', 'certificate_issue', 'success', '保留原记录', 'known-actor'),
('orphan', 'deleted-domain', 'certificate_issue', 'success', '保留原记录', ''),
('management', 'management-certificate', 'management_renewal', 'success', '保留原记录', ''),
('unknown', 'management-certificate', 'certificate_issue', 'success', '保留原记录', '')`,
`INSERT INTO task_attempts (id, task_id, attempt_number, stage, status, message) VALUES
('business-attempt', 'business', 1, 'issue', 'success', '保留原记录'),
('deleted-attempt', 'deleted-task', 1, 'issue', 'success', '保留原记录')`,
`INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES
('business', 'domain', 'domain', 'success', '保留原记录'),
('certificate', 'certificate', 'domain', 'success', '保留原记录'),
('server', 'server', 'server', 'success', '保留原记录'),
('orphan', 'domain', 'deleted-domain', 'success', '保留原记录'),
('task', 'task', 'business', 'success', '保留原记录'),
('deleted-task', 'task', 'deleted-task', 'success', '保留原记录'),
('management', 'management_certificate', 'management-certificate', 'success', '保留原记录'),
('unknown', 'management_certificate', 'different', 'success', '保留原记录'),
('auth', 'auth', 'login', 'success', '保留原记录')`,
} {
if _, err := db.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
}
func assertHistoryScope(t *testing.T, db *sql.DB) {
t.Helper()
rows, err := db.QueryContext(t.Context(), `SELECT id, scope_type, project_id, operator_id, message FROM tasks ORDER BY id`)
if err != nil {
t.Fatal(err)
}
count := 0
for rows.Next() {
var id, scope, project, actor, message string
if err := rows.Scan(&id, &scope, &project, &actor, &message); err != nil {
t.Fatal(err)
}
wantScope, wantProject, wantActor := "unknown", "", ""
if id == "business" {
wantScope, wantProject, wantActor = "project", "default", "known-actor"
}
if id == "management" {
wantScope = "system"
}
if scope != wantScope || project != wantProject || actor != wantActor || message != "保留原记录" {
t.Errorf("任务历史错误:%s %s %s %s", id, scope, project, actor)
}
count++
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
if count != 4 {
t.Fatalf("任务历史丢失:%d", count)
}
rows, err = db.QueryContext(t.Context(), `SELECT action, scope_type, project_id, task_id, operator_id, message FROM audit_logs`)
if err != nil {
t.Fatal(err)
}
count = 0
for rows.Next() {
var action, scope, project, task, actor, message string
if err := rows.Scan(&action, &scope, &project, &task, &actor, &message); err != nil {
t.Fatal(err)
}
wantScope, wantProject, wantTask := "unknown", "", ""
switch action {
case "business", "certificate", "server", "task":
wantScope, wantProject = "project", "default"
case "management", "auth":
wantScope = "system"
}
if action == "task" {
wantTask = "business"
}
if action == "deleted-task" {
wantTask = "deleted-task"
}
if scope != wantScope || project != wantProject || task != wantTask || actor != "" || message != "保留原记录" {
t.Errorf("审计历史错误:%s %s %s %s %s", action, scope, project, task, actor)
}
count++
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
if count != 9 {
t.Fatalf("审计历史丢失:%d", count)
}
if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM task_attempts WHERE message = '保留原记录'`).Scan(&count); err != nil || count != 2 {
t.Fatalf("历史尝试被改写或删除:%d,%v", count, err)
}
}
func TestHistoryScopeSQLiteMigration(t *testing.T) {
db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t))
if err != nil {
t.Fatal(err)
}
defer db.Close()
seedHistoryScope(t, db.DB, "sqlite")
before := snapshotSQLiteHistoricalBusiness(t, db.DB)
if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatal(err)
}
assertSQLiteHistoricalBusiness(t, db.DB, before)
assertHistoryScope(t, db.DB)
after := snapshotSQLiteHistoricalBusiness(t, db.DB)
if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatal(err)
}
assertSQLiteHistoricalBusiness(t, db.DB, after)
}
func TestHistoryScopeSQLiteMigrationRollback(t *testing.T) {
for _, tc := range []struct{ name, setup, repair string }{
{"version", `CREATE TRIGGER fail_history_version BEFORE INSERT ON schema_migrations WHEN NEW.version = 13 BEGIN SELECT RAISE(ABORT, 'test-only'); END`, `DROP TRIGGER fail_history_version`},
{"backfill", `CREATE TRIGGER fail_history_backfill BEFORE UPDATE ON audit_logs BEGIN SELECT RAISE(ABORT, 'test-only'); END`, `DROP TRIGGER fail_history_backfill`},
{"index", `CREATE INDEX idx_audit_project_history ON audit_logs(status)`, `DROP INDEX idx_audit_project_history`},
} {
t.Run(tc.name, func(t *testing.T) {
db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t))
if err != nil {
t.Fatal(err)
}
defer db.Close()
seedHistoryScope(t, db.DB, "sqlite")
if _, err := db.DB.ExecContext(t.Context(), tc.setup); err != nil {
t.Fatal(err)
}
before := snapshotSQLiteHistoricalBusiness(t, db.DB)
schemaQuery := `SELECT type, name, sql FROM sqlite_master ORDER BY name`
schema := snapshotSQLiteUpgradeQuery(t, db.DB, schemaQuery)
if err := Migrate(t.Context(), db.DB, "sqlite"); err == nil {
t.Fatal("故障迁移未拒绝")
}
assertSQLiteHistoricalBusiness(t, db.DB, before)
if snapshotSQLiteUpgradeQuery(t, db.DB, schemaQuery) != schema {
t.Fatal("失败迁移没有完整回滚模式")
}
if version, err := currentMigrationVersion(t.Context(), db.DB); err != nil || version != 12 {
t.Fatalf("失败迁移版本错误:%d,%v", version, err)
}
if _, err := db.DB.ExecContext(t.Context(), tc.repair); err != nil {
t.Fatal(err)
}
for repeat := 0; repeat < 2; repeat++ {
if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatal(err)
}
}
assertHistoryScope(t, db.DB)
})
}
}
func TestHistoryScopeMySQLMigrationRetry(t *testing.T) {
fixture := startRealMySQL(t)
for _, phase := range []string{"backfill", "version"} {
t.Run(phase, func(t *testing.T) {
db := fixture.database(t)
ctx := t.Context()
seedHistoryScope(t, db.DB, "mysql")
var schema string
if err := db.DB.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&schema); err != nil {
t.Fatal(err)
}
schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`"
target := "BEFORE INSERT ON " + schema + ".schema_migrations"
if phase == "backfill" {
target = "BEFORE UPDATE ON " + schema + ".audit_logs"
}
if _, err := fixture.admin.ExecContext(ctx, `CREATE TRIGGER `+schema+`.fail_history `+target+` FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'test-only'`); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, db.DB, "mysql"); err == nil {
t.Fatal("故障未阻断迁移")
}
if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != 12 {
t.Fatalf("失败版本错误:%d,%v", version, err)
}
if _, err := fixture.admin.ExecContext(ctx, "DROP TRIGGER "+schema+".fail_history"); err != nil {
t.Fatal(err)
}
for repeat := 0; repeat < 2; repeat++ {
if err := Migrate(ctx, db.DB, "mysql"); err != nil {
t.Fatal(err)
}
}
assertHistoryScope(t, db.DB)
if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != 13 {
t.Fatalf("重试版本错误:%d,%v", version, err)
}
})
}
}

View File

@ -14,6 +14,7 @@ import (
//go:embed migrations/sqlite/0010_projects.sql migrations/mysql/0010_projects.sql migrations/postgres/0010_projects.sql
//go:embed migrations/sqlite/0011_resource_projects.sql migrations/mysql/0011_resource_projects.sql migrations/postgres/0011_resource_projects.sql
//go:embed migrations/sqlite/0012_resource_replacement.sql migrations/mysql/0012_resource_replacement.sql migrations/postgres/0012_resource_replacement.sql
//go:embed migrations/sqlite/0013_history_scope.sql migrations/mysql/0013_history_scope.sql migrations/postgres/0013_history_scope.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -38,7 +39,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 12
const latestMigrationVersion = 13
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
@ -90,6 +91,11 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
return fmt.Errorf("执行数据库迁移失败:%w", err)
}
}
if version == 13 {
if err := backfillHistoryScope(ctx, db); err != nil {
return err
}
}
if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
return fmt.Errorf("记录迁移版本失败:%w", err)
}
@ -117,6 +123,12 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
return err
}
}
if version == 13 {
if err := backfillHistoryScope(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)"
@ -192,6 +204,8 @@ func migrationName(version int) string {
return "resource_projects"
case 12:
return "resource_replacement"
case 13:
return "history_scope"
default:
return "unknown"
}

View File

@ -0,0 +1,9 @@
ALTER TABLE tasks ADD COLUMN scope_type VARCHAR(16) NOT NULL DEFAULT 'unknown';
ALTER TABLE tasks ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN scope_type VARCHAR(16) NOT NULL DEFAULT 'unknown';
ALTER TABLE audit_logs ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN task_id VARCHAR(36) NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN operator_id VARCHAR(36) NOT NULL DEFAULT '';
CREATE INDEX idx_tasks_project_history ON tasks(project_id, created_at);
CREATE INDEX idx_audit_project_history ON audit_logs(project_id, created_at);
CREATE INDEX idx_audit_task_history ON audit_logs(task_id);

View File

@ -0,0 +1,9 @@
ALTER TABLE tasks ADD COLUMN scope_type TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE tasks ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN scope_type TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE audit_logs ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN task_id TEXT NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN operator_id TEXT NOT NULL DEFAULT '';
CREATE INDEX idx_tasks_project_history ON tasks(project_id, created_at);
CREATE INDEX idx_audit_project_history ON audit_logs(project_id, created_at);
CREATE INDEX idx_audit_task_history ON audit_logs(task_id);

View File

@ -0,0 +1,9 @@
ALTER TABLE tasks ADD COLUMN scope_type TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE tasks ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN scope_type TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE audit_logs ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN task_id TEXT NOT NULL DEFAULT '';
ALTER TABLE audit_logs ADD COLUMN operator_id TEXT NOT NULL DEFAULT '';
CREATE INDEX idx_tasks_project_history ON tasks(project_id, created_at);
CREATE INDEX idx_audit_project_history ON audit_logs(project_id, created_at);
CREATE INDEX idx_audit_task_history ON audit_logs(task_id);

View File

@ -50,6 +50,9 @@ func TestSQLiteBusinessUpgradePreservesDependentObjects(t *testing.T) {
t.Fatal("行号或生成列发生变化")
}
var count, actualForeignKeys int
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM trigger_events").Scan(&count); err != nil || count != 2 {
t.Fatalf("范围回填应保留并调用两次历史更新触发器:%d,%v", count, err)
}
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM task_links WHERE task_id = 'task'").Scan(&count); err != nil || count != 1 {
t.Fatalf("任务表重建触发了级联删除:%v", err)
}
@ -59,7 +62,7 @@ func TestSQLiteBusinessUpgradePreservesDependentObjects(t *testing.T) {
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO a_task_view VALUES ('task', '升级后视图写入'); INSERT INTO settings (key, value) VALUES ('after', '升级后外部触发器')`); err != nil {
t.Fatal(err)
}
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM trigger_events").Scan(&count); err != nil || count != 2 {
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM trigger_events").Scan(&count); err != nil || count != 4 {
t.Fatalf("触发器执行结果错误:%d,%v", count, err)
}
})

View File

@ -79,7 +79,7 @@ func (s *Server) authLogin(c *gin.Context) {
account, userID, passwordHash, err := s.authenticateAccount(c.Request.Context(), request, cfg)
if err != nil {
s.recordLoginFailure(key)
s.recordAuthAudit(c, "登录失败", "failed", "登录凭据校验失败")
s.recordAuthAudit(c, "", "登录失败", "failed", "登录凭据校验失败")
s.writeError(c, http.StatusUnauthorized, "账号或密码错误", nil)
return
}
@ -120,7 +120,7 @@ func (s *Server) authLogin(c *gin.Context) {
}
s.authMu.Unlock()
s.setAuthCookie(c, token, expiresAt)
s.recordAuthAudit(c, "登录成功", "success", "用户登录成功")
s.recordAuthAudit(c, userID, "登录成功", "success", "用户登录成功")
c.JSON(http.StatusOK, gin.H{
"authenticated": true,
"account": account.Username,
@ -339,12 +339,13 @@ func (s *Server) clearLoginFailures(key string) {
s.loginMu.Unlock()
}
func (s *Server) recordAuthAudit(c *gin.Context, action, status, message string) {
func (s *Server) recordAuthAudit(c *gin.Context, operatorID, action, status, message string) {
if s.audit == nil {
return
}
if err := s.audit.CreateAuditLog(c.Request.Context(), repository.AuditLog{
Action: action, ResourceType: "auth", ResourceID: "login", Status: status, Message: message,
OperatorID: operatorID,
Action: action, ResourceType: "auth", ResourceID: "login", Status: status, Message: message,
}); err != nil && s.logger != nil {
s.logger.Warn("保存登录审计日志失败", "error", err)
}

View File

@ -23,7 +23,8 @@ func (s *Server) restoreCertificateVersion(c *gin.Context) {
}
if s.audit != nil {
_ = s.audit.CreateAuditLog(c.Request.Context(), repository.AuditLog{
Action: "certificate_version_restore", ResourceType: "certificate", ResourceID: c.Param("id"), Status: "failed",
OperatorID: s.currentOperatorID(c),
Action: "certificate_version_restore", ResourceType: "certificate", ResourceID: c.Param("id"), Status: "failed",
Message: "恢复证书历史版本失败",
})
}
@ -32,7 +33,8 @@ func (s *Server) restoreCertificateVersion(c *gin.Context) {
}
if s.audit != nil {
_ = s.audit.CreateAuditLog(c.Request.Context(), repository.AuditLog{
Action: "certificate_version_restore", ResourceType: "certificate", ResourceID: c.Param("id"), Status: "success",
OperatorID: s.currentOperatorID(c),
Action: "certificate_version_restore", ResourceType: "certificate", ResourceID: c.Param("id"), Status: "success",
Message: "已恢复证书历史版本:" + version,
})
}

View File

@ -0,0 +1,84 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestHistoryScopeLoginAuditUsesVerifiedAccount(t *testing.T) {
server, _, audit, closeDB := newTaskAPITestServer(t, nil)
defer closeDB()
server.config.Admin.Username = "owner"
server.config.Admin.PasswordHash = httpUserPasswordHash(t, "OwnerPass123")
otherCookie := httpUserSessionCookie(server, "other-actor", repository.UserRoleAdmin)
for _, tc := range []struct {
name, password, actor string
cookie *http.Cookie
status int
}{
{"fresh-login", "OwnerPass123", "primary-super-admin", nil, http.StatusOK},
{"different-session", "OwnerPass123", "primary-super-admin", otherCookie, http.StatusOK},
{"failed-login", "Incorrect123", "", otherCookie, http.StatusUnauthorized},
} {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"owner","password":"`+tc.password+`"}`))
request.Header.Set("Content-Type", "application/json")
if tc.cookie != nil {
request.AddCookie(tc.cookie)
}
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != tc.status {
t.Fatalf("登录响应错误:%d,%s", response.Code, response.Body.String())
}
logs, err := audit.ListAuditLogs(t.Context(), 1)
if err != nil || len(logs) != 1 {
t.Fatalf("登录审计缺失:%+v,%v", logs, err)
}
if logs[0].OperatorID != tc.actor || logs[0].ScopeType != repository.ScopeSystem || logs[0].ProjectID != "" {
t.Fatalf("登录审计误用旧会话或丢失已验证身份:%+v", logs[0])
}
})
}
}
func TestHistoryScopeUserAuditSurvivesSessionInvalidation(t *testing.T) {
server, _, audit, closeDB := newTaskAPITestServer(t, nil)
defer closeDB()
cookie := httpUserSessionCookie(server, "self-admin", repository.UserRoleSuperAdmin)
response := serveUserRequest(server, cookie, http.MethodPut, "/api/v1/users/self-admin",
`{"username":"self-admin","email":"self-admin@example.test","password":"Updated123","role":"super_admin","status":"active"}`)
if response.Code != http.StatusOK {
t.Fatalf("更新自身账户失败:%d,%s", response.Code, response.Body.String())
}
logs, err := audit.ListAuditLogs(t.Context(), 1)
if err != nil || len(logs) != 1 || logs[0].OperatorID != "self-admin" || logs[0].ScopeType != repository.ScopeSystem {
t.Fatalf("会话失效后丢失实际操作者:%+v,%v", logs, err)
}
after := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/users", "")
if after.Code != http.StatusUnauthorized {
t.Fatalf("审计身份快照不得恢复失效会话:%d", after.Code)
}
}
func TestHistoryScopePasswordAuditSurvivesSessionInvalidation(t *testing.T) {
server, _, audit, closeDB := newTaskAPITestServer(t, nil)
defer closeDB()
cookie := httpUserSessionCookie(server, "password-user", repository.UserRoleUser)
response := performChangePasswordRequest(t, server, cookie,
`{"current_password":"FixturePass123","new_password":"Updated123","confirm_password":"Updated123"}`)
if response.Code != http.StatusOK {
t.Fatalf("修改密码失败:%d,%s", response.Code, response.Body.String())
}
logs, err := audit.ListAuditLogs(t.Context(), 1)
if err != nil || len(logs) != 1 || logs[0].OperatorID != "password-user" || logs[0].ScopeType != repository.ScopeSystem {
t.Fatalf("改密后丢失审计操作者:%+v,%v", logs, err)
}
if after := authSessionRequest(server, cookie); after.Code != http.StatusUnauthorized {
t.Fatalf("改密审计不得恢复旧会话:%d", after.Code)
}
}

View File

@ -137,7 +137,7 @@ func (s *Server) changeDatabaseUserPassword(c *gin.Context, session authSession,
func (s *Server) finishPasswordChange(c *gin.Context, userID, message string) {
s.invalidateUserSessions(userID)
s.clearAuthCookie(c)
s.recordAuthAudit(c, "修改密码", "success", "当前登录账号修改密码成功")
s.recordAuthAudit(c, userID, "修改密码", "success", "当前登录账号修改密码成功")
c.JSON(http.StatusOK, gin.H{
"authenticated": false,
"requires_login": true,

View File

@ -6,6 +6,13 @@ import (
)
// 归属由服务端会话填写,不能从请求体接受操作者或项目授权声明。
func (s *Server) currentOperatorID(c *gin.Context) string {
if session, ok := s.currentAuthSession(c); ok {
return session.UserID
}
return ""
}
func (s *Server) newResourceScope(c *gin.Context) domain.ResourceScope {
scope := domain.ResourceScope{ProjectID: domain.DefaultProjectID}
if session, ok := s.currentAuthSession(c); ok {

View File

@ -25,7 +25,8 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
return
}
domainID := strings.TrimSpace(c.Param("id"))
if _, err := s.repository.GetByID(c.Request.Context(), domainID); err != nil {
resource, err := s.repository.GetByID(c.Request.Context(), domainID)
if err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "域名不存在", err)
return
@ -66,7 +67,9 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
}
now := time.Now().UTC()
task := repository.Task{
ID: domain.NewID(), DomainID: domainID, TaskType: "certificate_issue", Status: "queued", Stage: "queued",
HistoryScope: repository.HistoryScope{ScopeType: repository.ScopeProject, ProjectID: resource.ResourceScope.Normalize().ProjectID},
OperatorID: s.currentOperatorID(c),
ID: domain.NewID(), DomainID: domainID, TaskType: "certificate_issue", Status: "queued", Stage: "queued",
Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now,
}
if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil {
@ -91,6 +94,7 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
_ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished)
if s.audit != nil {
_ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{
TaskID: task.ID, OperatorID: task.OperatorID,
Action: "certificate_issue", ResourceType: "domain", ResourceID: domainID,
Status: "error", Message: failureMessage,
})
@ -144,6 +148,7 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
auditStatus = "error"
}
_ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{
TaskID: task.ID, OperatorID: task.OperatorID,
Action: "certificate_issue", ResourceType: "domain", ResourceID: domainID, Status: auditStatus, Message: message,
})
}
@ -226,6 +231,7 @@ func (s *Server) cancelTask(c *gin.Context) {
item.UpdatedAt = finished
if s.audit != nil {
_ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{
TaskID: taskID, OperatorID: s.currentOperatorID(c),
Action: "task_cancel", ResourceType: "task", ResourceID: taskID, Status: "success", Message: item.Message,
})
}

View File

@ -47,6 +47,7 @@ func newTaskAPITestServer(t *testing.T, issue func(context.Context, string) (cer
IssueCertificate: issue,
Tasks: taskRepo,
Audit: auditRepo,
Users: repository.NewSQLUserRepository(db.DB, cfg.Database.Driver),
})
return server, taskRepo, auditRepo, func() { _ = db.Close() }
}
@ -74,6 +75,13 @@ func TestTaskAPIRecordsSuccessfulIssueAndAudit(t *testing.T) {
if err != nil || len(logs) != 1 || logs[0].Status != "success" {
t.Fatalf("审计日志记录错误:%+v %v", logs, err)
}
wantScope := repository.HistoryScope{ScopeType: repository.ScopeProject, ProjectID: domain.DefaultProjectID}
if items[0].HistoryScope != wantScope || attempts[0].HistoryScope != wantScope || logs[0].HistoryScope != wantScope {
t.Fatalf("请求任务、尝试与审计范围不一致:%+v %+v %+v", items[0], attempts[0], logs[0])
}
if items[0].OperatorID != "test-admin" || logs[0].OperatorID != "test-admin" || logs[0].TaskID != items[0].ID {
t.Fatalf("服务端会话操作者或审计任务关联缺失:%+v %+v", items[0], logs[0])
}
list := httptest.NewRecorder()
serveAdminHTTP(server, list, httptest.NewRequest(http.MethodGet, "/api/v1/tasks?limit=10", nil))
@ -215,7 +223,7 @@ func TestTaskAPICannotCancelManagementTaskWithoutExecutionControl(t *testing.T)
func TestTaskAPICanCancelRunningIssue(t *testing.T) {
started := make(chan struct{})
server, taskRepo, _, closeDB := newTaskAPITestServer(t, func(ctx context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
server, taskRepo, auditRepo, closeDB := newTaskAPITestServer(t, func(ctx context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
close(started)
<-ctx.Done()
return certificate.IssueResult{DomainID: id}, deployment.Report{}, ctx.Err()
@ -254,6 +262,15 @@ func TestTaskAPICanCancelRunningIssue(t *testing.T) {
if err != nil || item.Status != "cancelled" {
t.Fatalf("任务取消状态没有持久化:%+v %v", item, err)
}
logs, err := auditRepo.ListAuditLogs(t.Context(), 10)
if err != nil || len(logs) != 2 {
t.Fatalf("取消和申请收尾审计缺失:%+v,%v", logs, err)
}
for _, entry := range logs {
if entry.TaskID != item.ID || entry.OperatorID != "test-admin" || entry.HistoryScope != item.HistoryScope {
t.Fatalf("取消审计关联错误:%+v", entry)
}
}
}
func TestTaskAPIReplaysIdempotentIssue(t *testing.T) {
@ -362,12 +379,12 @@ func TestAuditLogResponseUsesDashboardFieldNames(t *testing.T) {
}
var previousID int64
for i, item := range payload.Items {
for _, field := range []string{"id", "action", "resource_type", "resource_id", "status", "message", "created_at"} {
for _, field := range []string{"id", "action", "resource_type", "resource_id", "status", "message", "created_at", "scope_type", "project_id", "task_id", "operator_id"} {
if _, ok := item[field]; !ok {
t.Fatal(field, response.Body.String())
}
}
if len(item) != 7 {
if len(item) != 11 {
t.Fatal(response.Body.String())
}
var id int64
@ -377,6 +394,7 @@ func TestAuditLogResponseUsesDashboardFieldNames(t *testing.T) {
previousID = id
for field, expected := range map[string]string{
"action": "login", "resource_type": "auth", "resource_id": "admin",
"scope_type": "system", "project_id": "", "task_id": "", "operator_id": "",
"status": []string{"error", "success"}[i], "message": "login", "created_at": createdAt.Format(time.RFC3339),
} {
var actual string

View File

@ -123,7 +123,7 @@ func (s *Server) createUser(c *gin.Context) {
s.writeError(c, userErrorStatus(err), "创建用户失败", err)
return
}
s.recordUserAudit(c, "创建用户", "success", item.ID)
s.recordUserAudit(c, s.currentOperatorID(c), "创建用户", "success", item.ID)
c.JSON(http.StatusCreated, item)
}
@ -183,12 +183,14 @@ func (s *Server) updateUser(c *gin.Context) {
return
}
}
// 更新自身账户会使原会话失效,审计身份必须在写入前从已验证会话读取。
operatorID := s.currentOperatorID(c)
if err := s.users.UpdateUser(c.Request.Context(), item); err != nil {
s.writeError(c, userErrorStatus(err), "更新用户失败", err)
return
}
s.invalidateUserSessions(id)
s.recordUserAudit(c, "更新用户", "success", id)
s.recordUserAudit(c, operatorID, "更新用户", "success", id)
c.JSON(http.StatusOK, item)
}
@ -222,6 +224,7 @@ func (s *Server) deleteUser(c *gin.Context) {
return
}
}
operatorID := s.currentOperatorID(c)
if err := s.users.DeleteUser(c.Request.Context(), id); err != nil {
if errors.Is(err, repository.ErrSuperAdminProtected) {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
@ -235,7 +238,7 @@ func (s *Server) deleteUser(c *gin.Context) {
return
}
s.invalidateUserSessions(id)
s.recordUserAudit(c, "删除用户", "success", id)
s.recordUserAudit(c, operatorID, "删除用户", "success", id)
c.Status(http.StatusNoContent)
}
@ -286,12 +289,13 @@ func userFromRequest(request userRequest, id string) (repository.User, error) {
return item, nil
}
func (s *Server) recordUserAudit(c *gin.Context, action, status, id string) {
func (s *Server) recordUserAudit(c *gin.Context, operatorID, action, status, id string) {
if s.audit == nil {
return
}
if err := s.audit.CreateAuditLog(c.Request.Context(), repository.AuditLog{
Action: action, ResourceType: "user", ResourceID: id, Status: status, Message: action,
OperatorID: operatorID,
Action: action, ResourceType: "user", ResourceID: id, Status: status, Message: action,
}); err != nil && s.logger != nil {
s.logger.Warn("保存用户审计日志失败", "error", err)
}

View File

@ -18,7 +18,8 @@ func (r *SQLAuditRepository) ListAuditLogs(ctx context.Context, limit int) ([]Au
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, action, resource_type, resource_id, status, message, created_at FROM audit_logs ORDER BY created_at DESC, id DESC LIMIT `+fmt.Sprintf("%d", limit)))
rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, action, resource_type, resource_id, status, message, created_at,
scope_type, project_id, task_id, operator_id FROM audit_logs ORDER BY created_at DESC, id DESC LIMIT `+fmt.Sprintf("%d", limit)))
if err != nil {
return nil, fmt.Errorf("查询审计日志失败:%w", err)
}
@ -26,7 +27,8 @@ func (r *SQLAuditRepository) ListAuditLogs(ctx context.Context, limit int) ([]Au
logs := make([]AuditLog, 0)
for rows.Next() {
var item AuditLog
if err := rows.Scan(&item.ID, &item.Action, &item.ResourceType, &item.ResourceID, &item.Status, &item.Message, &item.CreatedAt); err != nil {
if err := rows.Scan(&item.ID, &item.Action, &item.ResourceType, &item.ResourceID, &item.Status, &item.Message, &item.CreatedAt,
&item.ScopeType, &item.ProjectID, &item.TaskID, &item.OperatorID); err != nil {
return nil, fmt.Errorf("读取审计日志失败:%w", err)
}
item.CreatedAt = item.CreatedAt.UTC()

View File

@ -10,6 +10,9 @@ import (
)
type AuditLog struct {
HistoryScope
TaskID string `json:"task_id"`
OperatorID string `json:"operator_id"`
ID int64 `json:"id"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
@ -44,15 +47,28 @@ func (r *SQLAuditRepository) CreateAuditLog(ctx context.Context, item AuditLog)
item.ResourceType = strings.TrimSpace(item.ResourceType)
item.ResourceID = strings.TrimSpace(item.ResourceID)
item.Status = strings.TrimSpace(item.Status)
item.TaskID = strings.TrimSpace(item.TaskID)
item.OperatorID = strings.TrimSpace(item.OperatorID)
if item.Action == "" || item.ResourceType == "" || item.Status == "" {
return errors.New("审计日志必要字段不能为空")
}
if item.CreatedAt.IsZero() {
item.CreatedAt = time.Now().UTC()
}
_, err := r.db.ExecContext(ctx, r.query(`INSERT INTO audit_logs
(action, resource_type, resource_id, status, message, created_at)
VALUES (?, ?, ?, ?, ?, ?)`), item.Action, item.ResourceType, item.ResourceID, item.Status, item.Message, item.CreatedAt.UTC())
if item.ResourceType == "task" && item.TaskID == "" {
item.TaskID = item.ResourceID
}
scope, err := r.resolveScope(ctx, item)
if err != nil {
return fmt.Errorf("解析审计范围失败:%w", err)
}
if !item.HistoryScope.matches(scope) {
return errors.New("审计项目与资源范围不匹配")
}
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO audit_logs
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.Action, item.ResourceType, item.ResourceID, item.Status, item.Message,
item.CreatedAt.UTC(), scope.ScopeType, scope.ProjectID, item.TaskID, item.OperatorID)
if err != nil {
return fmt.Errorf("保存审计日志失败:%w", err)
}

View File

@ -0,0 +1,71 @@
package repository
import (
"context"
"database/sql"
"errors"
)
const (
ScopeProject = "project"
ScopeSystem = "system"
ScopeUnknown = "unknown"
ManagementResourceID = "management-certificate"
)
// HistoryScope 保存事件发生时的范围,资源或项目删除后仍保留;不是项目授权凭证。
// 未知历史归属保持为空,不自动降级为系统范围,也不归入默认项目。
type HistoryScope struct {
ScopeType string `json:"scope_type"`
ProjectID string `json:"project_id"`
}
func (s HistoryScope) matches(actual HistoryScope) bool {
return (s.ScopeType == "" || s.ScopeType == actual.ScopeType) && (s.ProjectID == "" || s.ProjectID == actual.ProjectID)
}
func (r *SQLAuditRepository) resolveScope(ctx context.Context, item AuditLog) (HistoryScope, error) {
if item.TaskID != "" {
task, err := NewSQLTaskRepository(r.db, r.driver).GetTask(ctx, item.TaskID)
if err != nil {
return HistoryScope{}, err
}
valid := item.ResourceType == "task" && item.ResourceID == task.ID
if item.ResourceType == "domain" || item.ResourceType == "certificate" {
valid = task.ScopeType != ScopeSystem && item.ResourceID == task.DomainID
}
if item.ResourceType == "management_certificate" {
valid = task.ScopeType == ScopeSystem && item.ResourceID == ManagementResourceID
}
if !valid {
return HistoryScope{}, errors.New("审计任务与资源不匹配")
}
return task.HistoryScope, nil
}
var table string
switch item.ResourceType {
case "auth", "user", "super_admin":
return HistoryScope{ScopeType: ScopeSystem}, nil
case "management_certificate":
if item.ResourceID == ManagementResourceID {
return HistoryScope{ScopeType: ScopeSystem}, nil
}
case "domain", "certificate":
table = "domains"
case "server":
table = "servers"
}
result := HistoryScope{ScopeType: ScopeUnknown}
if table != "" {
err := r.db.QueryRowContext(ctx, r.query("SELECT project_id FROM "+table+" WHERE id = ?"), item.ResourceID).Scan(&result.ProjectID)
if errors.Is(err, sql.ErrNoRows) {
// 对不存在资源的失败操作也需要审计,但不能伪造其项目。
return result, nil
}
if err != nil {
return HistoryScope{}, err
}
result.ScopeType = ScopeProject
}
return result, nil
}

View File

@ -0,0 +1,246 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"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"
)
func TestHistoryScopeSQLite(t *testing.T) {
cfg := config.Default(t.TempDir()).Database
cfg.DSN = filepath.Join(t.TempDir(), "history.db")
checkHistoryScope(t, cfg)
}
func TestHistoryScopeMySQLReal(t *testing.T) {
server := mysqltest.New(t)
checkHistoryScope(t, server.Database(t))
}
func checkHistoryScope(t *testing.T, cfg config.DatabaseConfig) {
t.Helper()
ctx := t.Context()
db, err := database.Open(ctx, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.Migrate(ctx, db.DB, cfg.Driver); err != nil {
t.Fatal(err)
}
projects := NewSQLProjectRepository(db.DB, cfg.Driver)
domains := NewSQLDomainRepository(db.DB, cfg.Driver)
tasks := NewSQLTaskRepository(db.DB, cfg.Driver)
audit := NewSQLAuditRepository(db.DB, cfg.Driver)
for _, id := range []string{"history-a", "history-b"} {
if err := projects.CreateProject(ctx, domain.Project{ID: id, Name: id}); err != nil {
t.Fatal(err)
}
}
resource, err := domain.NewDomain("history.example.test", domain.CertificateSingle, nil)
if err != nil {
t.Fatal(err)
}
resource.ProjectID = "history-a"
if err := domains.Create(ctx, resource); err != nil {
t.Fatal(err)
}
expected := HistoryScope{ScopeType: ScopeProject, ProjectID: "history-a"}
task := Task{ID: "history-task", DomainID: resource.ID, TaskType: "certificate_issue", Status: "running", Stage: "issue", OperatorID: "actor", IdempotencyKey: "history-key"}
if err := tasks.CreateTask(ctx, task); err != nil {
t.Fatal(err)
}
for name, read := range map[string]func() (Task, error){
"get": func() (Task, error) { return tasks.GetTask(ctx, task.ID) },
"active": func() (Task, error) { return tasks.FindActiveTaskByDomain(ctx, resource.ID, task.TaskType) },
"idempotency": func() (Task, error) { return tasks.FindTaskByIdempotencyKey(ctx, resource.ID, task.IdempotencyKey) },
} {
t.Run(name, func(t *testing.T) {
stored, err := read()
if err != nil || stored.HistoryScope != expected || stored.OperatorID != "actor" {
t.Fatalf("任务范围读取错误:%+v,%v", stored, err)
}
})
}
for _, invalid := range []Task{
{ID: "missing", DomainID: "missing", TaskType: "certificate_issue"},
{ID: "forged-project", DomainID: resource.ID, TaskType: "certificate_issue", HistoryScope: HistoryScope{ProjectID: "history-b"}},
{ID: "forged-system", DomainID: resource.ID, TaskType: "certificate_issue", HistoryScope: HistoryScope{ScopeType: ScopeSystem}},
{ID: "forged-unknown", DomainID: resource.ID, TaskType: "certificate_issue", HistoryScope: HistoryScope{ScopeType: ScopeUnknown}},
{ID: "false-management", DomainID: resource.ID, TaskType: "management_renewal"},
{ID: "false-resource", DomainID: ManagementResourceID, TaskType: "certificate_issue"},
{ID: "system-project", DomainID: ManagementResourceID, TaskType: "management_renewal", HistoryScope: expected},
} {
t.Run(invalid.ID, func(t *testing.T) {
invalid.Status, invalid.Stage = "queued", "queued"
if err := tasks.CreateTask(ctx, invalid); err == nil {
t.Fatal("无效任务范围未拒绝")
}
})
}
attempt := TaskAttempt{ID: "history-attempt", TaskID: task.ID, AttemptNumber: 1, Stage: "issue", Status: "running"}
if err := tasks.CreateAttempt(ctx, attempt); err != nil {
t.Fatal(err)
}
for _, invalid := range []TaskAttempt{
{ID: "missing-parent", TaskID: "missing"},
{ID: "wrong-project", TaskID: task.ID, HistoryScope: HistoryScope{ProjectID: "history-b"}},
{ID: "wrong-system", TaskID: task.ID, HistoryScope: HistoryScope{ScopeType: ScopeSystem}},
} {
t.Run(invalid.ID, func(t *testing.T) {
invalid.AttemptNumber, invalid.Stage, invalid.Status = 2, "issue", "running"
if err := tasks.CreateAttempt(ctx, invalid); err == nil {
t.Fatal("无效任务尝试关联未拒绝")
}
})
}
management := Task{ID: "management", DomainID: ManagementResourceID, TaskType: "management_renewal", Status: "running", Stage: "issue"}
if err := tasks.CreateTask(ctx, management); err != nil {
t.Fatal(err)
}
if stored, err := tasks.GetTask(ctx, management.ID); err != nil || stored.HistoryScope != (HistoryScope{ScopeType: ScopeSystem}) {
t.Fatalf("系统任务范围错误:%+v,%v", stored, err)
}
if err := tasks.CreateAttempt(ctx, TaskAttempt{ID: "management-attempt", TaskID: management.ID, AttemptNumber: 1, Stage: "issue", Status: "running"}); err != nil {
t.Fatal(err)
}
if attempts, err := tasks.ListAttempts(ctx, management.ID); err != nil || len(attempts) != 1 || attempts[0].HistoryScope != (HistoryScope{ScopeType: ScopeSystem}) {
t.Fatalf("系统尝试范围错误:%+v,%v", attempts, err)
}
for _, entry := range []AuditLog{
{Action: "direct", ResourceType: "domain", ResourceID: resource.ID},
{Action: "linked", ResourceType: "domain", ResourceID: resource.ID, TaskID: task.ID, OperatorID: "actor"},
{Action: "task", ResourceType: "task", ResourceID: task.ID},
{Action: "system", ResourceType: "management_certificate", ResourceID: ManagementResourceID, TaskID: management.ID},
{Action: "auth", ResourceType: "auth", ResourceID: "login"},
{Action: "missing", ResourceType: "domain", ResourceID: "deleted"},
} {
entry.Status = "success"
if err := audit.CreateAuditLog(ctx, entry); err != nil {
t.Fatal(entry.Action, err)
}
}
for _, entry := range []AuditLog{
{Action: "forged-project", ResourceType: "domain", ResourceID: resource.ID, HistoryScope: HistoryScope{ProjectID: "history-b"}},
{Action: "wrong-resource", ResourceType: "domain", ResourceID: "different", TaskID: task.ID},
{Action: "missing-task", ResourceType: "domain", ResourceID: resource.ID, TaskID: "missing"},
{Action: "wrong-system", ResourceType: "domain", ResourceID: resource.ID, TaskID: management.ID},
{Action: "wrong-task", ResourceType: "task", ResourceID: task.ID, TaskID: management.ID},
{Action: "false-system", ResourceType: "management_certificate", ResourceID: ManagementResourceID, TaskID: task.ID},
} {
t.Run(entry.Action, func(t *testing.T) {
entry.Status = "success"
if err := audit.CreateAuditLog(ctx, entry); err == nil {
t.Fatal("无效审计关联未拒绝")
}
})
}
finished := time.Now().UTC()
if err := tasks.CancelTask(ctx, task.ID, "测试取消", &finished); err != nil {
t.Fatal(err)
}
if err := domains.Delete(ctx, resource.ID); err != nil {
t.Fatal(err)
}
if err := projects.DeleteProject(ctx, "history-a"); err != nil {
t.Fatal(err)
}
// 资源和项目删除不删除任务,也不能让收尾审计丢失原项目。
if err := audit.CreateAuditLog(ctx, AuditLog{Action: "after-delete", ResourceType: "domain", ResourceID: resource.ID, TaskID: task.ID, Status: "cancelled"}); err != nil {
t.Fatal(err)
}
items, err := tasks.ListTasks(ctx, resource.ID, 10)
if err != nil || len(items) != 1 || items[0].HistoryScope != expected || items[0].Status != "cancelled" {
t.Fatalf("历史任务丢失:%+v,%v", items, err)
}
attempts, err := tasks.ListAttempts(ctx, task.ID)
if err != nil || len(attempts) != 1 || attempts[0].HistoryScope != expected || attempts[0].Status != "cancelled" {
t.Fatalf("尝试未继承历史范围:%+v,%v", attempts, err)
}
logs, err := audit.ListAuditLogs(ctx, 50)
if err != nil || len(logs) != 7 {
t.Fatalf("审计数量错误:%d,%v", len(logs), err)
}
for _, entry := range logs {
want := expected
if entry.Action == "system" || entry.Action == "auth" {
want = HistoryScope{ScopeType: ScopeSystem}
}
if entry.Action == "missing" {
want = HistoryScope{ScopeType: ScopeUnknown}
}
if entry.HistoryScope != want {
t.Fatalf("审计快照错误:%+v", entry)
}
if entry.Action == "linked" && (entry.TaskID != task.ID || entry.OperatorID != "actor") {
t.Fatalf("审计任务或操作者丢失:%+v", entry)
}
}
cancelled, cancel := context.WithCancel(ctx)
cancel()
if err := tasks.CreateTask(cancelled, management); err == nil {
t.Fatal("已取消上下文未生效")
}
checkHistoryAttemptBoundaries(t, domains, tasks)
}
func checkHistoryAttemptBoundaries(t *testing.T, domains *SQLDomainRepository, tasks *SQLTaskRepository) {
t.Helper()
for _, name := range []string{"deleted-resource", "changed-project", "unknown-parent", "orphan-attempt"} {
t.Run(name, func(t *testing.T) {
ctx := t.Context()
resource, err := domain.NewDomain(name+".example.test", domain.CertificateSingle, nil)
if err != nil {
t.Fatal(err)
}
resource.ProjectID = "history-b"
if err := domains.Create(ctx, resource); err != nil {
t.Fatal(err)
}
task := Task{ID: name, DomainID: resource.ID, TaskType: "certificate_issue", Status: "running", Stage: "issue"}
if err := tasks.CreateTask(ctx, task); err != nil {
t.Fatal(err)
}
attempt := TaskAttempt{ID: name, TaskID: task.ID, AttemptNumber: 1, Status: "running", Stage: "issue"}
if err := tasks.CreateAttempt(ctx, attempt); err != nil {
t.Fatal(err)
}
switch name {
case "deleted-resource":
err = domains.Delete(ctx, resource.ID)
case "changed-project":
_, err = tasks.db.ExecContext(ctx, tasks.query("UPDATE domains SET project_id = ? WHERE id = ?"), "default", resource.ID)
case "unknown-parent":
// 模拟保留下来的未知历史任务,不通过新建接口伪造范围。
_, err = tasks.db.ExecContext(ctx, tasks.query("UPDATE tasks SET scope_type = 'unknown', project_id = '' WHERE id = ?"), task.ID)
case "orphan-attempt":
_, err = tasks.db.ExecContext(ctx, tasks.query("DELETE FROM tasks WHERE id = ?"), task.ID)
}
if err != nil {
t.Fatal(err)
}
attempt.ID += "-retry"
attempt.AttemptNumber = 2
if err := tasks.CreateAttempt(ctx, attempt); err == nil {
t.Fatal("资源或历史范围失效后仍允许新增尝试")
}
stored, err := tasks.ListAttempts(ctx, task.ID)
if err != nil || len(stored) != 1 || stored[0].ID != name {
t.Fatalf("历史尝试丢失或新增拒绝后残留:%+v,%v", stored, err)
}
want := HistoryScope{ScopeType: ScopeProject, ProjectID: "history-b"}
if name == "unknown-parent" || name == "orphan-attempt" {
want = HistoryScope{ScopeType: ScopeUnknown}
}
if stored[0].HistoryScope != want {
t.Fatalf("历史尝试范围被当前资源覆盖:%+v", stored[0])
}
})
}
}

View File

@ -11,6 +11,7 @@ import (
// Task 表示一项可持久化的证书或部署任务。
type Task struct {
HistoryScope
ID string `json:"id"`
DomainID string `json:"domain_id"`
TaskType string `json:"task_type"`
@ -28,6 +29,7 @@ type Task struct {
// TaskAttempt 表示任务的一次执行尝试。
type TaskAttempt struct {
HistoryScope
ID string `json:"id"`
TaskID string `json:"task_id"`
AttemptNumber int `json:"attempt_number"`
@ -82,14 +84,35 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error {
if updated.IsZero() {
updated = now
}
_, err := r.db.ExecContext(ctx, r.query(`INSERT INTO tasks
(id, domain_id, task_type, status, stage, message, operator_id, certificate_version_id, idempotency_key, started_at, finished_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
args := []any{
item.ID, item.DomainID, item.TaskType, item.Status, item.Stage, item.Message, item.OperatorID,
item.CertificateVersionID, item.IdempotencyKey, nullableTime(item.StartedAt), nullableTime(item.FinishedAt), now, updated)
item.CertificateVersionID, item.IdempotencyKey, nullableTime(item.StartedAt), nullableTime(item.FinishedAt), now, updated,
}
query := `INSERT INTO tasks
(id, domain_id, task_type, status, stage, message, operator_id, certificate_version_id, idempotency_key, started_at, finished_at, created_at, updated_at, scope_type, project_id) `
if item.TaskType == "management_renewal" {
if item.DomainID != ManagementResourceID || !item.HistoryScope.matches(HistoryScope{ScopeType: ScopeSystem}) {
return errors.New("管理证书任务范围无效")
}
query += `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', '')`
} else {
if item.DomainID == ManagementResourceID || (item.ScopeType != "" && item.ScopeType != ScopeProject) {
return errors.New("业务任务必须归属于实际域名项目")
}
// 一条语句读取资源归属并写入快照,不能先检查再插入悬空任务。
query += `SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'project', d.project_id
FROM domains d JOIN projects p ON p.id = d.project_id WHERE d.id = ? AND (? = '' OR d.project_id = ?)`
args = append(args, item.DomainID, item.ProjectID, item.ProjectID)
}
result, err := r.db.ExecContext(ctx, r.query(query), args...)
if err != nil {
return fmt.Errorf("创建任务失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil {
return fmt.Errorf("读取任务创建结果失败:%w", err)
} else if count != 1 {
return errors.New("任务资源不存在或项目不匹配")
}
return nil
}
@ -297,13 +320,23 @@ func (r *SQLTaskRepository) CreateAttempt(ctx context.Context, item TaskAttempt)
if item.CreatedAt.IsZero() {
item.CreatedAt = time.Now().UTC()
}
_, err := r.db.ExecContext(ctx, r.query(`INSERT INTO task_attempts
result, err := r.db.ExecContext(ctx, r.query(`INSERT INTO task_attempts
(id, task_id, attempt_number, stage, status, message, started_at, finished_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.TaskID, item.AttemptNumber, item.Stage, item.Status, item.Message,
nullableTime(item.StartedAt), nullableTime(item.FinishedAt), item.CreatedAt.UTC())
SELECT ?, id, ?, ?, ?, ?, ?, ?, ? FROM tasks t WHERE id = ?
AND (? = '' OR scope_type = ?) AND (? = '' OR project_id = ?)
AND ((t.scope_type = 'system' AND t.task_type = 'management_renewal' AND t.domain_id = 'management-certificate')
OR (t.scope_type = 'project' AND EXISTS (SELECT 1 FROM domains d JOIN projects p ON p.id = d.project_id
WHERE d.id = t.domain_id AND d.project_id = t.project_id)))`), item.ID, item.AttemptNumber, item.Stage, item.Status, item.Message,
nullableTime(item.StartedAt), nullableTime(item.FinishedAt), item.CreatedAt.UTC(), item.TaskID,
item.ScopeType, item.ScopeType, item.ProjectID, item.ProjectID)
if err != nil {
return fmt.Errorf("创建任务尝试失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil {
return fmt.Errorf("读取任务尝试创建结果失败:%w", err)
} else if count != 1 {
return errors.New("任务尝试的父任务不存在或范围不匹配")
}
return nil
}
@ -345,7 +378,9 @@ func (r *SQLTaskRepository) ListAttempts(ctx context.Context, taskID string) ([]
if taskID == "" {
return nil, errors.New("任务 ID 不能为空")
}
rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, task_id, attempt_number, stage, status, message, started_at, finished_at, created_at FROM task_attempts WHERE task_id = ? ORDER BY attempt_number`), taskID)
rows, err := r.db.QueryContext(ctx, r.query(`SELECT a.id, a.task_id, a.attempt_number, a.stage, a.status, a.message,
a.started_at, a.finished_at, a.created_at, COALESCE(t.scope_type, 'unknown'), COALESCE(t.project_id, '')
FROM task_attempts a LEFT JOIN tasks t ON t.id = a.task_id WHERE a.task_id = ? ORDER BY a.attempt_number`), taskID)
if err != nil {
return nil, fmt.Errorf("查询任务尝试失败:%w", err)
}
@ -364,12 +399,12 @@ func (r *SQLTaskRepository) ListAttempts(ctx context.Context, taskID string) ([]
return result, nil
}
const taskSelect = `SELECT id, domain_id, task_type, status, stage, message, operator_id, certificate_version_id, idempotency_key, started_at, finished_at, created_at, updated_at FROM tasks`
const taskSelect = `SELECT id, domain_id, task_type, status, stage, message, operator_id, certificate_version_id, idempotency_key, started_at, finished_at, created_at, updated_at, scope_type, project_id FROM tasks`
func scanTask(row rowScanner) (Task, error) {
var item Task
var startedAt, finishedAt sql.NullTime
if err := row.Scan(&item.ID, &item.DomainID, &item.TaskType, &item.Status, &item.Stage, &item.Message, &item.OperatorID, &item.CertificateVersionID, &item.IdempotencyKey, &startedAt, &finishedAt, &item.CreatedAt, &item.UpdatedAt); err != nil {
if err := row.Scan(&item.ID, &item.DomainID, &item.TaskType, &item.Status, &item.Stage, &item.Message, &item.OperatorID, &item.CertificateVersionID, &item.IdempotencyKey, &startedAt, &finishedAt, &item.CreatedAt, &item.UpdatedAt, &item.ScopeType, &item.ProjectID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Task{}, sql.ErrNoRows
}
@ -383,7 +418,7 @@ func scanTask(row rowScanner) (Task, error) {
func scanAttempt(row rowScanner) (TaskAttempt, error) {
var item TaskAttempt
var startedAt, finishedAt sql.NullTime
if err := row.Scan(&item.ID, &item.TaskID, &item.AttemptNumber, &item.Stage, &item.Status, &item.Message, &startedAt, &finishedAt, &item.CreatedAt); err != nil {
if err := row.Scan(&item.ID, &item.TaskID, &item.AttemptNumber, &item.Stage, &item.Status, &item.Message, &startedAt, &finishedAt, &item.CreatedAt, &item.ScopeType, &item.ProjectID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return TaskAttempt{}, sql.ErrNoRows
}

View File

@ -24,6 +24,13 @@ func newTaskRepositoryTest(t *testing.T) (*SQLTaskRepository, *sql.DB) {
db.Close()
t.Fatalf("迁移测试数据库失败:%v", err)
}
for _, id := range []string{"domain-1", "domain-2", "domain-3"} {
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO domains (id, name, certificate_type, sans_json, status)
VALUES (?, ?, 'single', '[]', 'pending')`, id, id+".example.test"); err != nil {
db.Close()
t.Fatal(err)
}
}
return NewSQLTaskRepository(db.DB), db.DB
}

View File

@ -1,5 +1,7 @@
# Scert 开发任务清单
> 当前批次(模式 13):任务/审计范围快照、历史保留与回填、实际任务关联和操作者来源已实现;新增尝试拒绝缺失资源、项目变更和未知父任务。详细状态与测试以《Scert整改任务执行清单》首节和支持矩阵第二十节为准。其他资源、成员授权和生产验收继续实施,P0-05/P0-06/P0-07 保持 `[~]`,全部 44 项不缩减。下方各段保留历史证据,不表示最新状态。
> 最新执行状态:`7284958` 已交付域名/服务器归属、绑定约束与三库模式 11;`92c844f` 新增模式 12 修复 SQLite 替换目标行绕过引用保护,完整后端、三次竞态、MySQL 双版本及 Windows 独立程序回归均已通过。下方“归属仍未实现”只描述项目根实体历史批次。任务、审计等剩余资源与项目成员权限仍待接入,P0-06/P0-07 继续 `[~]`;全部 44 项范围不变。精确状态见《Scert整改任务执行清单》,证据及条件跳过见支持矩阵第十九节。
> 2026 年 9 月 28 日当前进度:P0-06 项目根实体子项 `[x]`,已新增三库第 10 号迁移、默认项目、项目模型和仓储;SQLite、MySQL 5.7/8.0、竞态与后端全量测试,以及独立 Windows 程序项目备份恢复通过。资源归属字段、回填、引用保护及项目授权仍未实现,P0-06/P0-07 总项保持 `[~]`。新证据及命令见支持矩阵第十八节;旧模式 1~9 的历史证据不自动升级为模式 10 的全部数据库验收。

View File

@ -1,5 +1,20 @@
# Scert 整改任务执行清单
## 2026 年 9 月 28 日:任务/审计范围快照(本批实现与回归完成,等待复审)
修改前检查点 `5237e8d` 已推送。当前批次接续模式 12,不改写旧迁移,不删除历史任务,不给已删除资源伪造默认项目,不伪造操作者。
- [x] 已实现三库模式 13 及历史回填;任务、审计保存范围快照,管理证书使用明确系统范围,未知历史保留未知。
- [x] 已实现业务任务从实际域名项目创建、尝试关联校验及历史读取;HTTP 操作者取自服务端会话,工作流审计关联实际任务。
- [x] 当前专项重测通过:`HistoryScope|TaskAPI|AuditLogResponse|SQLiteBusinessUpgradePreservesDependentObjects`,覆盖仓储、数据库、接口三包;未配置外部 MySQL 的条件用例不算实库通过。
- [x] 补充资源删除/项目变更后新尝试拒绝、历史孤儿读取、HTTP 与工作流任务和身份关联断言;正常管理续期必须有开始和收尾审计,故障注入场景只校验实际可写入记录,不强行要求被拒绝的开始审计存在。
- [x] 正式回归复现登录初版身份漏记/错记及自身账户更新后身份丢失;修复为本次已验证身份或写入前身份快照,登录失败不冒用旧 Cookie。改密审计保留实际身份,后续请求仍拒绝旧会话。
- [x] MySQL 5.7/8.0 专项各 135 个叶子通过;完整后端 1514 个叶子通过、49 项条件跳过;三次竞态共 258 个叶子通过、6 项条件跳过;最新 Windows 程序两条备份恢复路径、静态检查和模块校验通过。日志及统计边界见支持矩阵第二十节。
- [x] 同步总清单、执行清单、模型与接口字段、基础规格、开发清单、审查记录、支持矩阵及运维边界;本节与源码同批提交。
- [ ] 剩余资源关联、项目成员授权和撤权重判、跨项目越权验证、PostgreSQL 当前实库、Linux 原生与生产门禁。
范围快照没有项目外键,不用于授权,也不保证任意直接 SQL 写入的正确性。回填会正常执行既有更新触发器;若触发器拒绝,迁移失败,不绕过触发器。P0-05、P0-06、P0-07 仍为 `[~]`。
## 2026 年 9 月 28 日:资源归属与替换约束复核
### 已提交的第 11 号迁移(源码 `7284958`)

View File

@ -1,5 +1,16 @@
# Scert 整改、合并与优化清单
## 当前进度(2026 年 9 月 28 日,任务/审计关联批次)
- [x] 已提交:域名、服务器项目归属及同项目绑定约束(模式 11);SQLite 替换操作引用保护(模式 12)。已验证范围见执行清单和支持矩阵第十九节。
- [x] P0-06 本批子项:模式 13、任务/审计项目/系统/未知范围快照、任务与操作者关联、历史保留均已实现并通过本批回归,等待复审。登录错记身份及会话失效后漏记审计也已复现修复。
- [x] 完整后端 1514 个叶子通过,0 失败、49 项条件跳过;三次竞态共 258 个叶子通过;MySQL 双版本专项各 135 个叶子通过且无跳过(包含 SQLite 用例);独立 Windows 产品程序两条历史升级/备份恢复路径通过。精确命令和限制见支持矩阵第二十节。
- [ ] 剩余资源项目关联、项目成员权限、跨项目 Web/API 越权验证及生产部署验收仍未完成。
P0-05、P0-06、P0-07 总项均保持 `[~]`;本批字段与范围校验不等于项目授权完成。原始 44 项及其验收标准不缩减。最新细项见《Scert整改任务执行清单.md》首节;以下旧批次进度只作历史记录,不代表当前实现。
### 历史进度(按批次保留)
> 当前进度(2026 年 9 月 28 日):源码 `7284958` 已完成域名、服务器的四项归属字段、三库第 11 号迁移及同项目部署绑定约束;不是只有项目根实体。下方项目根实体段落属于上一批历史记录。复核进一步复现 SQLite 替换冲突可绕过引用保护,已由 `92c844f` 新增第 12 号迁移修复并通过本批回归,不能只修改旧迁移使已升级库漏修。最新状态见执行清单与支持矩阵第十九节。任务、审计等剩余资源关联、成员权限及生产验收未完成,P0-06/P0-07 均保持 `[~]`,全部 44 项范围不变。
> 本批最终验证补充:项目根实体已通过 SQLite 专项、MySQL 5.7/8.0 实库、3 次竞态检测及完整后端回归;默认和普通项目也已进入独立 Windows 程序的备份恢复验证。下方“待汇总”为启动阶段快照,最新证据以执行清单的“项目根实体最终验证结果”及支持矩阵第十八节为准。项目资源归属、关联约束和权限隔离仍未实现,P0-06/P0-07 保持 `[~]`。

View File

@ -1,5 +1,7 @@
# 全面审查、测试与优化记录
> 当前批次:模式 13 任务/审计关联及身份来源复核。审计新增身份初版存在新登录漏记、携带旧 Cookie 时错记、更新自身账户后会话失效导致漏记的问题,已用正式回归复现并修复;不缓存权限,不恢复失效会话。首轮测试夹具问题与过严断言也分别保留原日志。本批最终证据及限制见支持矩阵第二十节,不能复用下方模式 12 的数字替代本批验证。
> 最新复核:资源归属实现已提交为 `7284958`,上一批最终日志重新统计为后端 1432 个叶子通过、47 项条件跳过。本轮正式用例复现 SQLite 在关闭递归触发器时的 20 个替换绕过失败,新增第 12 号迁移修复,包含主键/隐藏行号及项目替换。失败证据保留于 `artifacts/resource-replacement-20260928/before.jsonl`,回归、干净构建和数据库专项最终以支持矩阵第十九节为准。下方根实体批次为历史记录;不宣称项目授权或生产验收完成。
> 2026 年 9 月 28 日最新批次:新增项目根实体、三库第 10 号迁移及仓储。最终后端 1409 个叶子通过、含父项 1535 条、0 失败、46 项条件跳过;54 个项目/迁移叶子用例连续 3 次竞态检测通过;MySQL 5.7.44/8.0.45 各 8 个叶子用例实库通过。独立 Windows 产品程序完成旧库升级及含普通项目的数据备份恢复。MySQL 8.0 首轮故障注入受触发器权限限制,已改用隔离管理连接注入并重测通过,没有降低安全配置或提高产品账号权限。证据见支持矩阵第十八节;项目资源字段和权限隔离仍未实现,生产未放行。

View File

@ -1,5 +1,7 @@
# 基础整改任务规格
> 本批进度(模式 13):P0-06 已增加任务/审计的项目、系统和未知范围快照及任务关联;HTTP 身份、后台任务、历史删除资源与孤儿尝试按实际来源处理。范围保存与当前资源校验不是成员授权,P0-05/P0-06/P0-07 均保持 `[~]`。本批证据见执行清单首节及支持矩阵第二十节;下方模式 10~12 说明为历史批次,不覆盖本批结果。
> 当前进度:域名/服务器归属、三库第 11 号迁移及绑定约束已随 `7284958` 提交;第 12 号迁移已随 `92c844f` 修复 SQLite 替换绕过并通过本批回归,详细状态见执行清单和支持矩阵第十九节。下方根实体批次与 46 项条件跳过为历史数据;资源批次全量是 47 项条件跳过,不能混用。任务、审计、其他资源及成员授权仍未完成,P0-05/P0-06/P0-07 总项均保持 `[~]`。
> 2026 年 9 月 28 日最终验证:项目根实体的 SQLite、MySQL 5.7/8.0 实库及后端回归已通过,替代下方启动阶段“持续验证”的状态;仅该子项为 `[x]`。完整资源关联与 P0-07 权限隔离仍为 `[~]`。详细证据、46 项条件跳过及 PostgreSQL/生产缺口见支持矩阵第十八节。

View File

@ -791,3 +791,64 @@ go test -json -count=1 -timeout=8m -run 'ResourceProject|SQLProjectRepositoryMyS
- [ ] 任务/审计等剩余资源项目关联、项目成员授权、跨项目 Web/API 越权验证和大规模性能准入。
本次整理只提交已有有效修复及必要文档,不部署生产,不提升 P0-05/P0-06/P0-07 总项状态,也不将 44 项整改目标标记为完成。
## 二十、任务/审计范围快照与模式 13(2026 年 9 月 28 日)
### 20.1 源码与交付边界
修改前检查点为 `5237e8d`,本节与模式 13 实现及最终回归测试同批提交。新增三库迁移、历史范围回填、仓储校验、HTTP 与后台任务关联。模式 12 的第十九节保留原证据,不以其结果替代本批验证。
任务范围分为项目、系统与未知;实际业务任务必须关联当前存在的域名项目,新尝试拒绝资源删除、项目改变或未知父任务。历史任务/审计是快照,不加级联项目外键,不伪造历史身份;孤儿尝试仍可读。范围字段与新建校验不等于项目成员授权,也不保证任意直接 SQL 写入的正确性。
### 20.2 最终验证结果
证据目录:`artifacts/history-scope-20260928/`。原始日志和构建程序位于 Git 忽略目录,不作为源码提交。下表终态会话均确认退出 0,叶子统计不把父测试计为独立用例,条件跳过不计无测试文件包。
| 验证 | 日志 | 结果 |
| --- | --- | --- |
| 完整后端 | `backend-final.jsonl` | 1514 个叶子通过、含父项 1649 条;0 失败,49 项条件跳过,28 个测试包通过 |
| 三次竞态专项 | `race-final.jsonl` | 三轮共 258 个叶子通过、含父项 294 条;0 失败,6 项条件跳过 |
| MySQL 5.7 | `mysql57-review.jsonl` | 135 个叶子通过、含父项 146 条;0 失败,0 跳过 |
| MySQL 8.0 | `mysql80-review.jsonl` | 135 个叶子通过、含父项 146 条;0 失败,0 跳过 |
| 登录与账户身份复测 | `actor-after.jsonl` | 28 个叶子通过、含父项 31 条;0 失败,0 跳过;之后又纳入最终全量及竞态验证 |
| 独立 Windows 产品程序 | 完整后端日志中的 `TestSQLiteBusinessUpgradeStandaloneCLI` | `historical_backup`、`upgraded_backup` 两条路径通过;校验模式 13、历史任务项目及此前引用保护 |
| 静态与模块 | `go vet ./...`、`go mod verify` | 最终竞态之后顺序执行,整体退出 0,模块全部校验通过 |
MySQL 专项同时执行 SQLite 用例,不是 135 个纯 MySQL 用例。使用既有 Go 隔离夹具、独立数据库、回环监听与严格 TLS;未清空生产数据。双版本完成后仅追加 HTTP 身份修复及其测试,数据库/仓储实现未改变,最终后端和竞态在身份修复后重新执行。
独立程序由本批提交前工作区构建,不称干净发行包。条件跳过代表对应外部环境未配置,不能据此宣称 PostgreSQL、Linux 或生产环境通过。
### 20.3 原始失败记录与修复
- `initial.jsonl`:审计响应字段增加后旧字段数断言失败;历史任务范围回填真实调用原有更新触发器,旧触发次数断言失败。更新正式预期,没有屏蔽触发器,也没有删历史数据。
- `review-targeted.jsonl`:新增管理续期断言误要求被故障注入拒绝的开始审计也存在。修正为通用断言检查实际审计关联,正常成功流程另行要求开始和收尾两条记录。
- `actor-before.jsonl`、`actor-fixture-before.jsonl`:首轮身份回归同时暴露测试夹具缺少账户查询、测试角色和密码不符合现有策略,原日志保留;未因此放宽产品权限或密码策略。
- `actor-verified-before.jsonl`:夹具修正后,四个失败叶子准确复现新登录无操作者、旧 Cookie 身份串用、失败登录误认旧身份及自身账户更新后身份丢失。修复使用本次认证结果/写入前会话身份;改密另补失效后审计与旧会话拒绝回归。
- SQLite 新迁移验证历史保留、二次迁移、回填触发器拒绝、索引冲突、版本登记失败的整体回滚和修复重试。MySQL 注入回填/版本登记故障并重试,DDL 部分完成时不清空历史或覆盖已确认范围。
### 20.4 实际命令
以下命令在 `backend/` 执行,无辅助验收脚本;JSON 日志在当前 Windows PowerShell 中按 Unicode 编码读取。
```powershell
go build -o ..\build\review-scert-history-windows-amd64.exe ./cmd/scert
$env:SCERT_SQLITE_TEST_CLI = 'D:\Desktop\Scert\build\review-scert-history-windows-amd64.exe'
go test -json -count=1 -timeout=10m ./...
go test -race -json -count=3 -timeout=6m -run 'HistoryScope|TaskAPI|AuditLogResponse|CertificateTask|ManagementRenewal|SQLiteBusinessUpgradePreservesDependentObjects|AuthLogin|ChangePassword|UserManagement' ./internal/repository ./internal/database ./internal/httpapi ./internal/app
go vet ./...
go mod verify
```
MySQL 分别设置 `SCERT_MYSQL_TEST_BIN` 为 `E:\DB\5.7\bin` 和 `E:\DB\8.0\bin`,`SCERT_MYSQL_TEST_ROOT` 指向 `D:\Desktop\Scert\artifacts\history-scope-20260928\mysql-isolated`。
```powershell
go test -json -count=1 -timeout=8m -run 'HistoryScope|ResourceProject|SQLProjectRepositoryMySQLReal|MySQLRealProjectMigrationRetry' ./internal/repository ./internal/database
```
### 20.5 仍未完成
- [ ] 剩余资源项目关联、成员关系与角色授权、撤权后的执行/重试/取消重新判权,以及跨项目列表、详情和危险操作越权验证。
- [ ] PostgreSQL 当前实库、完整三库备份恢复、Linux 原生、干净完整发行包、Windows Service/systemd 重启及生产部署门禁。
- [ ] 历史大数据回填和查询性能准入。
本批子项为 `[x]` 等待复审;P0-05、P0-06、P0-07 总项保持 `[~]`,全部 44 项目标继续执行,不提供生产放行结论。

View File

@ -1,5 +1,9 @@
# Scert 生产部署与运维说明
> 最新候选模式为 13:新增任务/审计范围快照与历史回填。必须使用支持模式 13 的程序打开已升级数据库,不能删除迁移记录降级。SQLite/PostgreSQL 在事务中回填;MySQL 可能保留部分 DDL,修复失败原因后使用同版迁移重试。回填会执行原有更新触发器,禁止绕过触发器或删除历史记录强行升级。未知归属和操作者保持未知,不自动补成默认项目或管理员。
> 本批只做隔离验证,不执行生产升级或清库。程序备份恢复、双版本 MySQL 及未覆盖项见支持矩阵第二十节;PostgreSQL 当前实库、Linux 原生、干净完整发行包、服务重启及生产门禁仍未完成。下方模式 10~12 提示为历史记录,不是当前程序版本,也不是生产放行许可。
> 当前模式提示(2026 年 9 月 28 日):模式 11 已增加域名/服务器归属与同项目绑定;新增模式 12 用于修复 SQLite 替换冲突对引用保护的绕过。下方模式 10 提示是上一批历史记录。必须用支持模式 12 的程序升级已有模式 11 数据库;只改旧迁移文件不能修复已登记的库,也不能用只支持模式 10/11 的旧程序直接打开升级后的数据库。禁止删除迁移记录强行降级,回退必须使用兼容程序和已验证备份。
> 升级发现悬空绑定或资源引用缺失项目时应拒绝继续,保留原库及模式,先核查真实业务再修复;不能自动删除旧任务、资源或绑定来通过迁移。SQLite 触发器冲突、版本登记失败也必须整体回滚后重试。当前证据见支持矩阵第十九节;本次未执行生产部署,成员授权、完整发行包、实机服务与全部平台门禁仍待验收。

View File

@ -1,6 +1,6 @@
# 项目资源关联模型
> 当前实现更新:第 11 号迁移及资源归属源码已提交为 `7284958`;第 12 号迁移正在修复复核发现的 SQLite 替换冲突绕过。下方第五节与第 10 号迁移说明是项目根实体批次的历史记录,不代表当前资源仍无归属。当前关系见第二、六、七节;最终证据见执行清单及支持矩阵第十九节。
> 当前实现更新:第 11、12 号迁移已提交;本批新增第 13 号迁移和任务/审计范围快照,见第七、八节。第五节是项目根实体历史批次,不代表最新资源状态。本批测试及未覆盖范围以执行清单首节、支持矩阵第二十节为准;项目成员授权仍未完成。
> 本批最终验证:SQLite、MySQL 5.7/8.0 项目专项、3 次竞态检测、完整后端及独立 Windows CLI 备份恢复已通过;第五节保留测试启动时状态,最终以执行清单和支持矩阵第十八节为准。P0-06/P0-07 仍为 `[~]`。
@ -24,13 +24,13 @@
| `users`、`roles` | 既有用户和角色实现;项目成员与角色分配待补 | 用户经项目成员关系获得权限,不能按用户组名称推导项目权限 |
| `domains`、`servers` | 第 11 号迁移已增加项目、所有者、创建人和更新人 | 历史数据回填默认项目,未知身份留空;第 12 号迁移补强 SQLite 替换保护;成员授权仍待完成 |
| `domain_servers` | 已保存项目并校验双方一致性 | 事务失败保持原绑定;三库引用约束已实现,PostgreSQL 当前实库待验 |
| `tasks`、`task_attempts` | 既有任务与尝试表;尚未增加项目隔离 | 项目及资源归属、执行人、幂等与任务尝试关联;执行前重新判权 |
| `tasks`、`task_attempts` | 模式 13 任务保存范围快照,尝试读取父任务范围;历史孤儿返回未知 | 已校验新任务/尝试与当前资源一致;项目成员授权、撤权重判待补 |
| `certificates`、`certificate_versions`、`certificate_orders` | 证书业务已有实现;统一实体关系待补 | 项目、域名、订单、版本、任务和签发结果可追溯 |
| `dns_accounts`、`dns_delegations` | DNS 配置业务已有实现;统一实体关系待补 | 项目、所有者、凭据引用、委派及验证任务关联 |
| `deployment_targets`、`deployment_records` | 部署业务已有实现;统一实体关系待补 | 关联项目、服务器、证书版本、任务尝试和回滚记录 |
| `renewal_policies` | 续期业务已有实现;统一实体关系待补 | 关联项目、证书、计划和执行任务 |
| `notification_channels`、`notification_records` | 通知业务已有实现;统一实体关系待补 | 关联项目、事件、任务、通道和发送结果 |
| `audit_logs` | 既有审计表;尚未增加项目隔离 | 项目、操作者、资源、任务与危险操作结果可追踪;全局操作单独定义范围 |
| `audit_logs` | 模式 13 保存范围、任务、操作者;登录、账户、证书流程已接入 | 历史无法确定的身份留空,系统操作单独定义范围;项目级读取授权待补 |
| `backup_records` | 数据库备份恢复已有实现;统一实体关系待补 | 区分系统备份与项目备份,记录范围、校验及恢复任务,不泄露私钥 |
## 三、第 10 号迁移历史记录
@ -71,12 +71,28 @@ SQLite 第 11 号迁移安装触发器,第 12 号迁移进一步保护替换
MySQL/PostgreSQL 第 11 号迁移建立项目外键与绑定复合外键,第 12 号只登记统一版本,不声称新增或验证两库特有的替换语义。项目字段和约束不是成员授权,现有跨项目列表、私钥、DNS 等权限门禁仍需单独交付。
## 七、下一批任务与审计关联约束
## 七、第 13 号迁移:任务与审计关联
- [x] 确认三条实际任务入口:HTTP 申请任务、证书工作流任务和管理证书续期任务;审计另有登录、用户和本地重置等系统入口。
- [ ] 区分业务项目任务、系统管理证书任务和历史未知归属;不能把 `management-certificate` 当作真实业务域名,也不能伪造历史操作者。
- [ ] 确定已删除资源的任务与审计保留策略;迁移不清空历史记录,新增任务必须核对项目与资源,审计记录系统或项目范围及任务关联。
- [ ] 任务尝试从所属任务继承范围;执行、取消、重试和撤权后的排队任务重新判权,不能以建字段代替授权。
- [ ] 三库迁移、回滚/重试、仓储读写、接口及真实备份恢复完整覆盖后,才标记相应子项完成。
- [x] 已实现业务项目、系统管理证书与历史未知三种范围;管理任务同时校验特殊资源 ID 和任务类型,不创建伪造业务域名。
- [x] 已实现历史保留与可确认关系回填;新增任务通过单条 SQL 从实际域名项目写入快照,拒绝悬空资源和伪造范围。
- [x] 尝试从父任务读取范围,新增尝试校验当前资源仍存在且项目未变;历史孤儿可读,不再新增尝试。
- [x] 审计显式关联任务时核对资源,保留删除后的历史范围;HTTP 身份取服务端验证结果,不接受客户端自报操作者。后台及未知历史不伪造用户。
- [ ] 执行、取消、重试及撤权后的完整项目成员重新判权;当前范围校验不是权限授权。
- [~] SQLite、MySQL 专项和独立程序验证见支持矩阵;PostgreSQL 当前实库、完整三库恢复及生产验证仍未齐备。
第 12 号已用于替换保护修复,后续新增结构从第 13 号迁移开始。上述待办没有因本批约束修复而取消。
历史快照刻意不建立项目外键,以免删除资源或项目时丢失审计与任务。产品仓储约束新建入口,不声称防止任意直接 SQL 篡改。历史审计只回填可证实的任务/资源关系,不按时间猜测所属任务,也不把任务操作者冒充审计操作者。
SQLite 与 PostgreSQL 在迁移事务内回填,失败回滚;MySQL DDL 可能已部分执行,重试跳过既有结构、仅补未知范围,完成后登记模式 13。回填会触发现存更新触发器;触发器拒绝时失败,不绕过。SQLite 已验证回填触发器、索引冲突和版本登记故障;PostgreSQL 此批只有迁移实现,没有实库证据。
## 八、任务与审计接口字段增量
本节说明 Scert 接口;同目录《后端接口说明文档.md》是 Smail 发信集成参考,不向其中混入 Scert 路由。
| 接口 | 新增响应字段 | 含义 |
| --- | --- | --- |
| `GET /api/v1/tasks`、`GET /api/v1/tasks/:id` | `scope_type`、`project_id` | 任务创建时的范围快照;已有 `operator_id` 保存实际 HTTP 发起人,后台任务为空 |
| `GET /api/v1/tasks/:id/attempts` | `scope_type`、`project_id` | 从父任务继承;缺少父任务的历史尝试返回 `unknown` 和空项目 |
| `GET /api/v1/audit-logs` | `scope_type`、`project_id`、`task_id`、`operator_id` | 审计事件范围、明确关联的任务和本次已验证操作者;不可确认的标识为空 |
`scope_type` 为 `project`、`system` 或 `unknown`;这些字段仅表达归属,不是访问令牌。登录审计使用本次认证成功的用户,失败不把请求声明或旧 Cookie 当成已验证身份;更新自身账户、改密导致会话失效后仍保存实际操作者,但不恢复旧会话。既有权限限制继续生效,新增项目成员权限尚未交付。