diff --git a/backend/internal/app/certificate_tasks.go b/backend/internal/app/certificate_tasks.go index 699a575..f6354ad 100644 --- a/backend/internal/app/certificate_tasks.go +++ b/backend/internal/app/certificate_tasks.go @@ -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 { diff --git a/backend/internal/app/certificate_tasks_test.go b/backend/internal/app/certificate_tasks_test.go index cd27cfe..78a3939 100644 --- a/backend/internal/app/certificate_tasks_test.go +++ b/backend/internal/app/certificate_tasks_test.go @@ -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 } diff --git a/backend/internal/app/management_renewal.go b/backend/internal/app/management_renewal.go index 6d94fa2..1c6c6c3 100644 --- a/backend/internal/app/management_renewal.go +++ b/backend/internal/app/management_renewal.go @@ -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 { diff --git a/backend/internal/app/management_renewal_test.go b/backend/internal/app/management_renewal_test.go index 02a9eee..2d4cbb9 100644 --- a/backend/internal/app/management_renewal_test.go +++ b/backend/internal/app/management_renewal_test.go @@ -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) diff --git a/backend/internal/cli/sqlite_upgrade_cli_test.go b/backend/internal/cli/sqlite_upgrade_cli_test.go index ebc67e3..1b44c20 100644 --- a/backend/internal/cli/sqlite_upgrade_cli_test.go +++ b/backend/internal/cli/sqlite_upgrade_cli_test.go @@ -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 { diff --git a/backend/internal/database/history_scope.go b/backend/internal/database/history_scope.go new file mode 100644 index 0000000..c6f25fa --- /dev/null +++ b/backend/internal/database/history_scope.go @@ -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 +} diff --git a/backend/internal/database/history_scope_test.go b/backend/internal/database/history_scope_test.go new file mode 100644 index 0000000..a8333f2 --- /dev/null +++ b/backend/internal/database/history_scope_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index eaff615..01bbed5 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -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" } diff --git a/backend/internal/database/migrations/mysql/0013_history_scope.sql b/backend/internal/database/migrations/mysql/0013_history_scope.sql new file mode 100644 index 0000000..6317e51 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0013_history_scope.sql @@ -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); diff --git a/backend/internal/database/migrations/postgres/0013_history_scope.sql b/backend/internal/database/migrations/postgres/0013_history_scope.sql new file mode 100644 index 0000000..5a8aabb --- /dev/null +++ b/backend/internal/database/migrations/postgres/0013_history_scope.sql @@ -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); diff --git a/backend/internal/database/migrations/sqlite/0013_history_scope.sql b/backend/internal/database/migrations/sqlite/0013_history_scope.sql new file mode 100644 index 0000000..5a8aabb --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0013_history_scope.sql @@ -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); diff --git a/backend/internal/database/sqlite_task_migration_test.go b/backend/internal/database/sqlite_task_migration_test.go index 7fccc70..58459bd 100644 --- a/backend/internal/database/sqlite_task_migration_test.go +++ b/backend/internal/database/sqlite_task_migration_test.go @@ -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) } }) diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go index db35bf8..806c42e 100644 --- a/backend/internal/httpapi/auth.go +++ b/backend/internal/httpapi/auth.go @@ -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) } diff --git a/backend/internal/httpapi/certificate_restore_api.go b/backend/internal/httpapi/certificate_restore_api.go index e0bab35..c345f39 100644 --- a/backend/internal/httpapi/certificate_restore_api.go +++ b/backend/internal/httpapi/certificate_restore_api.go @@ -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, }) } diff --git a/backend/internal/httpapi/history_scope_audit_test.go b/backend/internal/httpapi/history_scope_audit_test.go new file mode 100644 index 0000000..ecd4067 --- /dev/null +++ b/backend/internal/httpapi/history_scope_audit_test.go @@ -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) + } +} diff --git a/backend/internal/httpapi/password_api.go b/backend/internal/httpapi/password_api.go index e4eb56d..bbf8a51 100644 --- a/backend/internal/httpapi/password_api.go +++ b/backend/internal/httpapi/password_api.go @@ -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, diff --git a/backend/internal/httpapi/resource_scope.go b/backend/internal/httpapi/resource_scope.go index b10d98b..d2e62b3 100644 --- a/backend/internal/httpapi/resource_scope.go +++ b/backend/internal/httpapi/resource_scope.go @@ -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 { diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 7c9690a..fc039eb 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -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, }) } diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 0cb92e0..2f0a988 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -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 diff --git a/backend/internal/httpapi/users_api.go b/backend/internal/httpapi/users_api.go index 43bef36..fade8bf 100644 --- a/backend/internal/httpapi/users_api.go +++ b/backend/internal/httpapi/users_api.go @@ -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) } diff --git a/backend/internal/repository/audit_reader.go b/backend/internal/repository/audit_reader.go index 23ed44e..caf28ce 100644 --- a/backend/internal/repository/audit_reader.go +++ b/backend/internal/repository/audit_reader.go @@ -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() diff --git a/backend/internal/repository/audit_repository.go b/backend/internal/repository/audit_repository.go index 731cdcf..3171328 100644 --- a/backend/internal/repository/audit_repository.go +++ b/backend/internal/repository/audit_repository.go @@ -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) } diff --git a/backend/internal/repository/history_scope.go b/backend/internal/repository/history_scope.go new file mode 100644 index 0000000..317ce15 --- /dev/null +++ b/backend/internal/repository/history_scope.go @@ -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 +} diff --git a/backend/internal/repository/history_scope_test.go b/backend/internal/repository/history_scope_test.go new file mode 100644 index 0000000..d53a4e4 --- /dev/null +++ b/backend/internal/repository/history_scope_test.go @@ -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]) + } + }) + } +} diff --git a/backend/internal/repository/task_repository.go b/backend/internal/repository/task_repository.go index 7b51a82..d829d12 100644 --- a/backend/internal/repository/task_repository.go +++ b/backend/internal/repository/task_repository.go @@ -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 } diff --git a/backend/internal/repository/task_repository_test.go b/backend/internal/repository/task_repository_test.go index 7e74f17..7c2debf 100644 --- a/backend/internal/repository/task_repository_test.go +++ b/backend/internal/repository/task_repository_test.go @@ -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 } diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 5546e33..88b9ba2 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -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 的全部数据库验收。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index d48b4cd..4bdbde3 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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`) diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index f055835..afcf139 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -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 保持 `[~]`。 diff --git a/doc/全面审查测试与优化记录.md b/doc/全面审查测试与优化记录.md index be56bdf..fdadfd1 100644 --- a/doc/全面审查测试与优化记录.md +++ b/doc/全面审查测试与优化记录.md @@ -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 首轮故障注入受触发器权限限制,已改用隔离管理连接注入并重测通过,没有降低安全配置或提高产品账号权限。证据见支持矩阵第十八节;项目资源字段和权限隔离仍未实现,生产未放行。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 1d7e599..a96ae42 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -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/生产缺口见支持矩阵第十八节。 diff --git a/doc/构建版本与数据库支持矩阵.md b/doc/构建版本与数据库支持矩阵.md index 474876c..b0ce0f8 100644 --- a/doc/构建版本与数据库支持矩阵.md +++ b/doc/构建版本与数据库支持矩阵.md @@ -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 项目标继续执行,不提供生产放行结论。 diff --git a/doc/生产部署与运维说明.md b/doc/生产部署与运维说明.md index 0723616..1f982b0 100644 --- a/doc/生产部署与运维说明.md +++ b/doc/生产部署与运维说明.md @@ -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 触发器冲突、版本登记失败也必须整体回滚后重试。当前证据见支持矩阵第十九节;本次未执行生产部署,成员授权、完整发行包、实机服务与全部平台门禁仍待验收。 diff --git a/doc/项目资源关联模型.md b/doc/项目资源关联模型.md index d936cef..bc74e4e 100644 --- a/doc/项目资源关联模型.md +++ b/doc/项目资源关联模型.md @@ -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 当成已验证身份;更新自身账户、改密导致会话失效后仍保存实际操作者,但不恢复旧会话。既有权限限制继续生效,新增项目成员权限尚未交付。