From 408bd5f8abac9ef3c150ae90b69447394ec277a2 Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 29 Sep 2026 17:51:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9D=83=E9=99=90=E6=95=B4=E6=94=B9=EF=BC=9A?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E9=A1=B9=E7=9B=AE=E5=AE=A1=E8=AE=A1=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AF=BB=E5=8F=96=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完成项目审计日志读取权限隔离、路由调整、测试及整改清单进度同步。 --- .../internal/httpapi/project_resources_api.go | 1 + backend/internal/httpapi/server.go | 2 +- backend/internal/httpapi/task_api.go | 7 +- backend/internal/httpapi/task_api_test.go | 6 ++ .../repository/project_audit_reader.go | 55 +++++++++++ .../repository/project_audit_reader_test.go | 97 +++++++++++++++++++ doc/Scert开发任务清单.md | 15 +++ doc/Scert整改任务执行清单.md | 15 +++ doc/Scert整改合并优化清单.md | 15 +++ doc/基础整改任务规格.md | 15 +++ 10 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 backend/internal/repository/project_audit_reader.go create mode 100644 backend/internal/repository/project_audit_reader_test.go diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index 96f0fa6..2e3ba0b 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.go @@ -12,6 +12,7 @@ import ( func (s *Server) registerProjectResourceRoutes() { api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated) api.GET("/domains", s.listDomains) + api.GET("/audit-logs", s.listAuditLogs) api.GET("/servers", s.listServers) api.GET("/servers/:id", s.getServer) api.GET("/domains/:id/servers", s.listDomainServers) diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 4a52815..54f7082 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -217,7 +217,7 @@ func (s *Server) registerRoutes() { adminAPI.GET("/tasks/:id", s.getTask) adminAPI.DELETE("/tasks/:id", s.cancelTask) adminAPI.GET("/tasks/:id/attempts", s.listTaskAttempts) - adminAPI.GET("/audit-logs", s.listAuditLogs) + adminAPI.GET("/settings", s.getSettings) adminAPI.PUT("/settings", s.updateSettings) adminAPI.GET("/dns/providers", s.listAdminDNSProviders) diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index fc039eb..94966e9 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -302,13 +302,16 @@ func (s *Server) listTaskAttempts(c *gin.Context) { } func (s *Server) listAuditLogs(c *gin.Context) { - reader, ok := s.audit.(repository.AuditReader) + if !s.requireProjectResourceReader(c) { + return + } + reader, ok := s.projectResources.(repository.ProjectAuditReader) if !ok { s.writeError(c, http.StatusNotImplemented, "审计日志查询接口尚未配置", errors.New("审计日志读取服务未配置")) return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) - items, err := reader.ListAuditLogs(c.Request.Context(), limit) + items, err := reader.ListVisibleAuditLogs(c.Request.Context(), c.GetString(authenticatedActorKey), limit) if err != nil { s.writeError(c, http.StatusInternalServerError, "查询审计日志失败", err) return diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 2f0a988..82f40ac 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -28,6 +28,11 @@ func newTaskAPITestServer(t *testing.T, issue func(context.Context, string) (cer db.Close() t.Fatalf("迁移测试数据库失败:%v", err) } + if _, err := db.DB.ExecContext(context.Background(), repository.Rebind(cfg.Database.Driver, `INSERT INTO users + (id, username, password_hash, role, status) VALUES ('test-admin', 'test-admin', 'test-only-no-login', 'admin', 'active')`)); err != nil { + db.Close() + t.Fatalf("创建测试管理员失败:%v", err) + } domainRepo := repository.NewSQLDomainRepository(db.DB) taskRepo := repository.NewSQLTaskRepository(db.DB) auditRepo := repository.NewSQLAuditRepository(db.DB) @@ -47,6 +52,7 @@ func newTaskAPITestServer(t *testing.T, issue func(context.Context, string) (cer IssueCertificate: issue, Tasks: taskRepo, Audit: auditRepo, + ProjectResources: repository.NewSQLProjectMemberRepository(db.DB, cfg.Database.Driver), Users: repository.NewSQLUserRepository(db.DB, cfg.Database.Driver), }) return server, taskRepo, auditRepo, func() { _ = db.Close() } diff --git a/backend/internal/repository/project_audit_reader.go b/backend/internal/repository/project_audit_reader.go new file mode 100644 index 0000000..62f2e9d --- /dev/null +++ b/backend/internal/repository/project_audit_reader.go @@ -0,0 +1,55 @@ +package repository + +import ( + "context" + "fmt" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +// ProjectAuditReader 只返回当前账户有权查看的项目审计记录。 +type ProjectAuditReader interface { + ListVisibleAuditLogs(context.Context, string, int) ([]AuditLog, error) +} + +var _ ProjectAuditReader = (*SQLProjectMemberRepository)(nil) + +func (r *SQLProjectMemberRepository) ListVisibleAuditLogs(ctx context.Context, actorID string, limit int) ([]AuditLog, error) { + if err := r.resourceReaderReady(actorID); err != nil { + return nil, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT logs.id, logs.action, + logs.resource_type, logs.resource_id, logs.status, logs.message, logs.created_at, + logs.scope_type, logs.project_id, logs.task_id, logs.operator_id + FROM audit_logs logs WHERE EXISTS ( + SELECT 1 FROM users actor WHERE actor.id = ? AND actor.status = 'active' + AND (actor.role IN ('super_admin', 'admin') OR (actor.role = 'user' + AND logs.scope_type = 'project' AND EXISTS ( + SELECT 1 FROM project_members member + WHERE member.project_id = logs.project_id AND member.user_id = actor.id + AND member.role IN (?, ?))))) + ORDER BY logs.created_at DESC, logs.id DESC LIMIT ?`), + actorID, string(domain.ProjectRoleAdmin), string(domain.ProjectRoleAuditViewer), limit) + if err != nil { + return nil, fmt.Errorf("查询项目审计日志失败:%w", err) + } + defer rows.Close() + items := 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, &item.ScopeType, &item.ProjectID, + &item.TaskID, &item.OperatorID); err != nil { + return nil, fmt.Errorf("读取项目审计日志失败:%w", err) + } + item.CreatedAt = item.CreatedAt.UTC() + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取项目审计日志列表失败:%w", err) + } + return items, nil +} diff --git a/backend/internal/repository/project_audit_reader_test.go b/backend/internal/repository/project_audit_reader_test.go new file mode 100644 index 0000000..5f000a6 --- /dev/null +++ b/backend/internal/repository/project_audit_reader_test.go @@ -0,0 +1,97 @@ +package repository + +import ( + "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" +) + +func TestProjectAuditReaderScopesLogsAndHonorsRevocation(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil { + t.Fatal(err) + } + for _, user := range []struct { + id string + role string + }{ + {"root", UserRoleSuperAdmin}, + {"global-admin", UserRoleAdmin}, + {"viewer", UserRoleUser}, + } { + if _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users + (id, username, password_hash, role, status) VALUES (?, ?, 'test-only-no-login', ?, 'active')`), + user.id, user.id, user.role); err != nil { + t.Fatal(err) + } + } + projects := NewSQLProjectRepository(db.DB, cfg.Driver) + for _, id := range []string{"project-a", "project-b"} { + if err := projects.CreateProject(t.Context(), domain.Project{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + domains := NewSQLDomainRepository(db.DB, cfg.Driver) + for _, item := range []struct { + id string + projectID string + name string + }{{"domain-a", "project-a", "a.example.test"}, {"domain-b", "project-b", "b.example.test"}} { + domainItem, err := domain.NewDomain(item.name, domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + domainItem.ID, domainItem.ProjectID = item.id, item.projectID + if err := domains.Create(t.Context(), domainItem); err != nil { + t.Fatal(err) + } + } + members := NewSQLProjectMemberRepository(db.DB, cfg.Driver) + if err := members.GrantProjectRole(t.Context(), "root", "project-a", "viewer", domain.ProjectRoleAuditViewer); err != nil { + t.Fatal(err) + } + audit := NewSQLAuditRepository(db.DB, cfg.Driver) + createdAt := time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC) + for _, item := range []AuditLog{ + {HistoryScope: HistoryScope{ScopeType: ScopeProject, ProjectID: "project-a"}, Action: "project-a", ResourceType: "domain", ResourceID: "domain-a", Status: "success", Message: "a", CreatedAt: createdAt}, + {HistoryScope: HistoryScope{ScopeType: ScopeProject, ProjectID: "project-b"}, Action: "project-b", ResourceType: "domain", ResourceID: "domain-b", Status: "success", Message: "b", CreatedAt: createdAt.Add(time.Minute)}, + {HistoryScope: HistoryScope{ScopeType: ScopeSystem}, Action: "system", ResourceType: "auth", ResourceID: "login", Status: "success", Message: "system", CreatedAt: createdAt.Add(2 * time.Minute)}, + } { + if err := audit.CreateAuditLog(t.Context(), item); err != nil { + t.Fatal(err) + } + } + reader := ProjectAuditReader(members) + items, err := reader.ListVisibleAuditLogs(t.Context(), "viewer", 50) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("项目审计范围错误:%+v", items) + } + items, err = reader.ListVisibleAuditLogs(t.Context(), "global-admin", 50) + if err != nil { + t.Fatal(err) + } + if len(items) != 4 { + t.Fatalf("系统管理员审计范围错误:%+v", items) + } + if err := members.RevokeProjectRole(t.Context(), "root", "project-a", "viewer", domain.ProjectRoleAuditViewer); err != nil { + t.Fatal(err) + } + items, err = reader.ListVisibleAuditLogs(t.Context(), "viewer", 50) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("撤权后仍返回审计日志:%+v", items) + } +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 32e29c4..d7a6fa1 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3358,3 +3358,18 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - `[!]` 本地服务启动被执行策略拒绝,本轮实际浏览器验收未执行,未尝试绕过。 - `[!]` 写权限探测逐级尝试父托管区的边界需专项整改;只读精准检查不等于写路径安全完成。 - `[!]` 真实初始化、正式 ACME、HTTPS/SNI、业务推送、升级回退及目标平台验收仍未完成。 + +## 本轮开发进度(2026-09-29 17:50) + +按总体验收口径,当前仍为 **P0-03 完成待复审**;本轮只推进 P0-07 的项目审计读取权限,未将局部开发或自动化测试标记为复审通过。 + +- [x] 审计日志路由改为登录后的项目资源路由,不再依赖全局管理员路由作为唯一入口。 +- [x] 新增项目审计读取仓储,在 SQL 查询中按实时账户状态、全局角色、项目成员角色和日志范围过滤。 +- [x] 普通项目成员仅可读取其拥有 udit.read 能力项目的项目审计;不能读取其他项目或系统范围日志。 +- [x] 超级管理员和全局管理员保留系统审计读取能力;账户停用后不再返回日志。 +- [x] 新增撤权立即生效、跨项目隔离和全局管理员范围回归测试。 +- [x] 已通过 go test ./... 和 go vet ./...;这只证明本地自动化验证通过,不代表生产部署验收通过。 +- [~] 任务读取、任务取消/重试、证书签发/导入/部署、DNS、SSH 及其他资源写入的完整项目权限链仍待开发。 +- [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 + +下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 117fb87..a73775d 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -660,3 +660,18 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - `[x]` 干净提交构建的独立 `scert.exe` 实际执行历史模式 3 备份、正式应用入口升级、旧备份恢复后再升级,以及升级后备份恢复;核对完整表/模式、资源绑定、用户密码、加密凭据及证书。 - `[x]` SQLite 专项 71 个叶子用例,27 个升级/恢复用例连续 3 次竞态检测;后端全量、静态/模块检查和 Linux 测试包交叉编译通过,证据见支持矩阵第十七节。 - `[ ]` 旧发行包产生的实际历史数据与配置升级、完整发行包安装、真实服务重启、Linux 原生执行、取消边界及其余数据库/平台和生产门禁。历史模式由现有迁移文件构造,不冒充旧发行二进制运行证据;全部 44 项范围不变。 + +## 本轮开发进度(2026-09-29 17:50) + +按总体验收口径,当前仍为 **P0-03 完成待复审**;本轮只推进 P0-07 的项目审计读取权限,未将局部开发或自动化测试标记为复审通过。 + +- [x] 审计日志路由改为登录后的项目资源路由,不再依赖全局管理员路由作为唯一入口。 +- [x] 新增项目审计读取仓储,在 SQL 查询中按实时账户状态、全局角色、项目成员角色和日志范围过滤。 +- [x] 普通项目成员仅可读取其拥有 udit.read 能力项目的项目审计;不能读取其他项目或系统范围日志。 +- [x] 超级管理员和全局管理员保留系统审计读取能力;账户停用后不再返回日志。 +- [x] 新增撤权立即生效、跨项目隔离和全局管理员范围回归测试。 +- [x] 已通过 go test ./... 和 go vet ./...;这只证明本地自动化验证通过,不代表生产部署验收通过。 +- [~] 任务读取、任务取消/重试、证书签发/导入/部署、DNS、SSH 及其他资源写入的完整项目权限链仍待开发。 +- [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 + +下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 9a67d37..58c7b95 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1296,3 +1296,18 @@ P0-11 生产验收门禁 5. 每完成一个任务,补充验证命令和验收证据。 6. 代码标识符使用英文,界面、注释、文档和用户提示使用中文。 7. 每次测试区分本地、隔离环境、测试环境和生产验收结果。 + +## 本轮开发进度(2026-09-29 17:50) + +按总体验收口径,当前仍为 **P0-03 完成待复审**;本轮只推进 P0-07 的项目审计读取权限,未将局部开发或自动化测试标记为复审通过。 + +- [x] 审计日志路由改为登录后的项目资源路由,不再依赖全局管理员路由作为唯一入口。 +- [x] 新增项目审计读取仓储,在 SQL 查询中按实时账户状态、全局角色、项目成员角色和日志范围过滤。 +- [x] 普通项目成员仅可读取其拥有 udit.read 能力项目的项目审计;不能读取其他项目或系统范围日志。 +- [x] 超级管理员和全局管理员保留系统审计读取能力;账户停用后不再返回日志。 +- [x] 新增撤权立即生效、跨项目隔离和全局管理员范围回归测试。 +- [x] 已通过 go test ./... 和 go vet ./...;这只证明本地自动化验证通过,不代表生产部署验收通过。 +- [~] 任务读取、任务取消/重试、证书签发/导入/部署、DNS、SSH 及其他资源写入的完整项目权限链仍待开发。 +- [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 + +下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index ef5a94b..d8bb34b 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -257,3 +257,18 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收 - 实机验收步骤:每数据库创建完整资源并备份,向空环境恢复、解密并实际部署证书;损坏备份必须在修改前拒绝;恢复中断后可回到原状态;核对恢复前备份、下载授权、定时记录和审计。远端测试不做无关整机备份,产品备份能力不能因此省略。 - 验收证据:部分数据库恢复历史证据存在;全部资源、自动备份和失败回滚闭环待补。 - 复审结果:未完成。 + +## 本轮开发进度(2026-09-29 17:50) + +按总体验收口径,当前仍为 **P0-03 完成待复审**;本轮只推进 P0-07 的项目审计读取权限,未将局部开发或自动化测试标记为复审通过。 + +- [x] 审计日志路由改为登录后的项目资源路由,不再依赖全局管理员路由作为唯一入口。 +- [x] 新增项目审计读取仓储,在 SQL 查询中按实时账户状态、全局角色、项目成员角色和日志范围过滤。 +- [x] 普通项目成员仅可读取其拥有 udit.read 能力项目的项目审计;不能读取其他项目或系统范围日志。 +- [x] 超级管理员和全局管理员保留系统审计读取能力;账户停用后不再返回日志。 +- [x] 新增撤权立即生效、跨项目隔离和全局管理员范围回归测试。 +- [x] 已通过 go test ./... 和 go vet ./...;这只证明本地自动化验证通过,不代表生产部署验收通过。 +- [~] 任务读取、任务取消/重试、证书签发/导入/部署、DNS、SSH 及其他资源写入的完整项目权限链仍待开发。 +- [!] PostgreSQL/MySQL 矩阵、浏览器、Linux/Windows 服务、远端 SSH、HTTPS/DNS/ACME 和生产验收仍未执行。 + +下一步:继续补齐任务与证书/部署权限链,完成开发阶段后再统一运行三库、浏览器和远端生产流程测试。