feat: 审计 CSV 导出 — 用户/API Key/集群操作日志一键下载

新增端点 (read 级, 审计 viewer 角色即可导出):
- GET /audit/users.csv: 账号清单 + 最后登录时间
- GET /audit/apikeys.csv: 密钥清单 (前缀+scope+最后使用+过期)
- GET /audit/cluster-log.csv: 令牌环操作日志时间线
  (seq/time_utc/node/kind/detail/data_json 六列, detail 为人读摘要,
   data_json 保留无损原始载荷)

安全设计:
- RFC4180 转义 (引号/逗号/换行)
- 公式注入防御: =/+/@/tab/- 开头单元格加 ' 前缀
- ISO8601 UTC 时间戳, Excel 直接排序
- 明文密钥不可逆, 仅导出展示前缀

前端:
- UsersView: 账号表/API 密钥表各加「⤓ 导出 CSV」按钮
- ClusterView: 日志导出下拉新增 CSV 选项 (走服务端生成)
- api.ts: downloadAuditCsv() 统一下载管道

测试: csvEscape 全用例 / users+apikeys CSV 内容断言 / 未认证 401
This commit is contained in:
JianFeeeee
2026-08-24 22:52:32 +08:00
parent 4a41608d94
commit ef98d9dca1
10 changed files with 645 additions and 231 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title>
<script type="module" crossorigin src="/assets/index-BUbn_fgs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQV0nqgE.css">
<script type="module" crossorigin src="/assets/index-CepJKoxg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B4L3dftx.css">
</head>
<body>
<div id="app"></div>

View File

@ -0,0 +1,186 @@
// Audit exports: CSV downloads of the evidence tables an auditor needs —
// user accounts (with last-login), API keys (with last-use), and the ring
// operation log. All read-level: exporting is exactly what a viewer/auditor
// role exists for. CSV cells are RFC4180-escaped; timestamps are ISO8601 UTC
// so spreadsheets sort them correctly.
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"webui4frpc/internal/cluster"
)
// csvEscape quotes a cell per RFC4180: wrap in double quotes when the value
// contains quote/comma/newline, doubling embedded quotes. Prefixing a leading
// '=' '+' '@' '\t' with an apostrophe defuses spreadsheet formula injection
// (CSV cells are data, not formulas — auditors open these in Excel).
func csvEscape(v string) string {
if v == "" {
return ""
}
if strings.ContainsAny(v, ",\"\n\r") {
v = `"` + strings.ReplaceAll(v, `"`, `""`) + `"`
}
if len(v) > 0 && (v[0] == '=' || v[0] == '+' || v[0] == '@' || v[0] == '\t' || v[0] == '-') {
return "'" + v
}
return v
}
// isoTime renders unix seconds as ISO8601 UTC ("2026-08-24T12:00:00Z"); 0 →
// empty (never logged / never used).
func isoTime(unix int64) string {
if unix <= 0 {
return ""
}
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
}
// writeCSV sets attachment headers and streams the header row + rows.
func writeCSV(w http.ResponseWriter, filename string, header []string, rows [][]string) {
var b strings.Builder
writeRow := func(cells []string) {
for i, c := range cells {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(csvEscape(c))
}
b.WriteString("\r\n")
}
writeRow(header)
for _, r := range rows {
writeRow(r)
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
_, _ = w.Write([]byte(b.String()))
}
// handleAuditUsersCsv serves GET /audit/users.csv: the account inventory with
// last-login timestamps. Admin-only? No — read-level: auditors (viewer role)
// are precisely the people who need this; password hashes were never part of
// the User JSON shape and are not included here either.
func (h *Handler) handleAuditUsersCsv(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
users, err := h.Store.ListUsers()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rows := make([][]string, 0, len(users))
for _, u := range users {
rows = append(rows, []string{
strconv.FormatInt(u.ID, 10),
u.Username,
u.Role,
strconv.FormatBool(u.Enabled),
strconv.FormatBool(u.System),
isoTime(u.CreatedAt),
isoTime(u.LastLoginAt),
})
}
writeCSV(w,
fmt.Sprintf("audit-users-%s.csv", time.Now().UTC().Format("20060102-150405")),
[]string{"id", "username", "role", "enabled", "system", "created_at", "last_login_at"},
rows)
}
// handleAuditApiKeysCsv serves GET /audit/apikeys.csv: key inventory with
// scope, last-use and expiry. The plaintext key is unrecoverable by design;
// only the display prefix is exported.
func (h *Handler) handleAuditApiKeysCsv(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
keys, err := h.Store.ListApiKeys()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Resolve owning username for readability.
nameOf := map[int64]string{}
if users, err := h.Store.ListUsers(); err == nil {
for _, u := range users {
nameOf[u.ID] = u.Username
}
}
rows := make([][]string, 0, len(keys))
for _, k := range keys {
expires := ""
if k.ExpiresAt != 0 {
expires = isoTime(k.ExpiresAt)
}
rows = append(rows, []string{
strconv.FormatInt(k.ID, 10),
k.Prefix + "…",
nameOf[k.UserID],
k.Label,
k.Scope,
isoTime(k.CreatedAt),
isoTime(k.LastUsedAt),
expires,
})
}
writeCSV(w,
fmt.Sprintf("audit-apikeys-%s.csv", time.Now().UTC().Format("20060102-150405")),
[]string{"id", "key_prefix", "owner", "label", "scope", "created_at", "last_used_at", "expires_at"},
rows)
}
// handleAuditClusterLogCsv serves GET /audit/cluster-log.csv: this node's ring
// operation log (forward add/remove, join/leave, leader changes, claims) as
// one flat CSV sorted by seq — the "who did what to the cluster" timeline.
// Data payloads are flattened into a human-readable detail column plus raw
// JSON for lossless reprocessing.
func (h *Handler) handleAuditClusterLogCsv(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
snap := h.Ring.Snapshot()
entries := snap.Log
rows := make([][]string, 0, len(entries))
for _, e := range entries {
rows = append(rows, []string{
strconv.FormatInt(e.Seq, 10),
isoTime(e.At),
e.Node,
e.Kind,
cluster.DetailOf(e),
rawJSON(e.Data),
})
}
writeCSV(w,
fmt.Sprintf("audit-cluster-log-%s.csv", time.Now().UTC().Format("20060102-150405")),
[]string{"seq", "time_utc", "node", "kind", "detail", "data_json"},
rows)
}
// rawJSON renders the log entry's payload as compact JSON (empty when absent)
// for the lossless audit column.
func rawJSON(data json.RawMessage) string {
if len(data) == 0 {
return ""
}
var buf bytes.Buffer
if err := json.Compact(&buf, data); err != nil {
return string(data)
}
return buf.String()
}

View File

@ -0,0 +1,110 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"webui4frpc/internal/store"
)
// newAuditHarness builds a Handler + mux with a temp store, mirroring
// TestSaveCanvasPublishesRevokeTask's setup minus the ring.
func newAuditHarness(t *testing.T) (*Handler, http.Handler) {
t.Helper()
dir := t.TempDir()
st, err := store.New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if _, err := st.CreateUser("auditor", "pw-auditor", "viewer"); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateApiKey(1, "ci-key", "read"); err != nil {
t.Fatal(err)
}
h := &Handler{Store: st, WorkDir: dir, User: "admin", Password: "pw"}
mux, err := NewServeMux(h)
if err != nil {
t.Fatal(err)
}
return h, mux
}
func TestCsvEscape(t *testing.T) {
cases := []struct{ in, want string }{
{"plain", "plain"},
{"", ""},
{"a,b", `"a,b"`},
{`say "hi"`, `"say ""hi"""`},
{"line\nbreak", "\"line\nbreak\""},
{"=cmd()", "'=cmd()"}, // formula injection defused
{"+1+1", "'+1+1"}, // formula injection defused
{"@SUM(A1)", "'@SUM(A1)"}, // formula injection defused
{"-2+3", "'-2+3"}, // formula injection defused
}
for _, c := range cases {
if got := csvEscape(c.in); got != c.want {
t.Errorf("csvEscape(%q)=%q want %q", c.in, got, c.want)
}
}
}
func TestIsoTimeEmpty(t *testing.T) {
if got := isoTime(0); got != "" {
t.Errorf("isoTime(0)=%q want empty", got)
}
}
func TestAuditUsersCsv(t *testing.T) {
_, mux := newAuditHarness(t)
req := httptest.NewRequest(http.MethodGet, "/api/manager/audit/users.csv", nil)
req.SetBasicAuth("admin", "pw")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"id,username,role,enabled,system,created_at,last_login_at", "auditor,viewer"} {
if !strings.Contains(body, want) {
t.Errorf("CSV missing %q:\n%s", want, body)
}
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
t.Errorf("content-type=%q", ct)
}
}
func TestAuditApiKeysCsv(t *testing.T) {
_, mux := newAuditHarness(t)
req := httptest.NewRequest(http.MethodGet, "/api/manager/audit/apikeys.csv", nil)
req.SetBasicAuth("admin", "pw")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "key_prefix,owner,label,scope") {
t.Errorf("CSV header missing:\n%s", body)
}
if !strings.Contains(body, "w4f_") || !strings.Contains(body, "ci-key") {
t.Errorf("key row missing:\n%s", body)
}
}
func TestAuditEndpointsNeedAuth(t *testing.T) {
_, mux := newAuditHarness(t)
for _, path := range []string{"/api/manager/audit/users.csv", "/api/manager/audit/apikeys.csv"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s without auth: status=%d want 401", path, rec.Code)
}
}
}

View File

@ -111,6 +111,12 @@ func NewServeMux(h *Handler) (http.Handler, error) {
mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing))
mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs))
mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport))
// Audit exports (CSV): read-level — exporting evidence is exactly what a
// viewer/auditor role exists for. RFC4180 CSV, ISO8601 UTC timestamps.
mux.HandleFunc(apiPrefix+"/audit/users.csv", h.auth("read")(h.handleAuditUsersCsv))
mux.HandleFunc(apiPrefix+"/audit/apikeys.csv", h.auth("read")(h.handleAuditApiKeysCsv))
mux.HandleFunc(apiPrefix+"/audit/cluster-log.csv", h.auth("read")(h.handleAuditClusterLogCsv))
mux.HandleFunc(apiPrefix+"/cluster/token", h.auth("write")(h.handleClusterToken))
mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin))
mux.HandleFunc(apiPrefix+"/cluster/task", h.auth("write")(h.handleClusterTask))