Files
Cyrene_v2/internal/services/gateway/admin_approval_test.go
T
AskaEth 63236c9b00
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
2026-10-04 12:30:16 +08:00

231 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件锁定裁决 13.4.3 的对外侧:敏感操作经代审后**落 audit_logs**
// (who/what/when/result)并向 owner/admin 广播 design/11 §4 的小字帧
// `approval_notice`。判定/代审本身由 internal/capability/restrict 的 Gate
// 钉住(restrict/approve_test.go),这里钉住「记录 + 呈现」,以及
// design/11 §7.1 的帧门(viewer 收不到这条内容帧)。
// approvalReviewer 是 restrict.Reviewer 的桩:记录调用并固定批准。
type approvalReviewer struct {
calls int
allowed bool
}
func (r *approvalReviewer) ReviewAndApprove(context.Context, string, string, string) (bool, error) {
r.calls++
return r.allowed, nil
}
// newApprovalEnv 接线一个带**真实 SQLite 审计存储**的网关 —— 用例要断言
// audit_logs 里真的有行,因此不能用内存回退存储。
func newApprovalEnv(t *testing.T) (*Server, string, *auth.JWTService, *sql.DB) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { conn.Close() })
auditStore, err := NewSQLiteAuditStore(ctx, conn, nil)
if err != nil {
t.Fatalf("audit store: %v", err)
}
srv, ts, jwt := buildEnv(t, nil, Deps{AuditStore: auditStore})
return srv, ts.URL, jwt, conn
}
// ownerWSConn 建立一条 owner 连接,并用一次 mode_switch 往返确认它已注册
// 到 hub(与既有的 flow_update/role-gate 用例同法)。
func ownerWSConn(t *testing.T, url, token string) *websocket.Conn {
t.Helper()
conn := dialWS(t, url, token)
t.Cleanup(func() { conn.Close() })
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
t.Fatalf("write mode_switch: %v", err)
}
if typ, raw := readWire(t, conn, "mode_switch ack"); typ != "mode_switch" {
t.Fatalf("first frame = %q, want mode_switch (%s)", typ, raw)
}
return conn
}
// TestRecordApproval_TerminalTriggersApprovalAndLandsInAuditLogs 是裁决 13.4.3
// 的反向用例(gateway 侧):terminal 这类敏感调用**走代审**——判定敏感 →
// 代审放行 → audit_logs 落一行 → 前端收到小字帧。此前 terminal 直接执行、
// 审计链里没有「代审」痕迹,本用例就是把那条链钉死。
func TestRecordApproval_TerminalTriggersApprovalAndLandsInAuditLogs(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv, url, jwt, conn := newApprovalEnv(t)
wsConn := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner))
args := map[string]any{"command": "echo hi"}
if _, _, sensitive := restrict.SensitiveCall("terminal", args); !sensitive {
t.Fatal("terminal 必须被判定为敏感操作(否则代审链根本不会触发)")
}
reviewer := &approvalReviewer{allowed: true}
gate := restrict.NewGate(reviewer, func(context.Context) (string, string) { return "web", "昔涟" })
decision := gate.Admit(ctx, "terminal", args)
if !decision.Allow || decision.Approval == nil {
t.Fatalf("代审批准后应放行并留下记录: %+v", decision)
}
if reviewer.calls != 1 {
t.Fatalf("代审者调用次数 = %d, want 1", reviewer.calls)
}
srv.RecordApproval(ctx, *decision.Approval)
// ① 前端小字帧(design/11 §4 的固定句式)。
typ, raw := readWire(t, wsConn, "approval_notice")
if typ != wsApprovalNotice {
t.Fatalf("frame type = %q, want %q (%s)", typ, wsApprovalNotice, raw)
}
var frame struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
Data struct {
Actor string `json:"actor"`
Tool string `json:"tool"`
Op string `json:"op"`
Detail string `json:"detail"`
Explain string `json:"explain"`
Allowed bool `json:"allowed"`
At string `json:"at"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("decode frame %s: %v", raw, err)
}
if frame.SessionID != "web" {
t.Fatalf("frame sessionId = %q, want web(小字要投到发生代审的那个会话)", frame.SessionID)
}
if frame.Data.Explain != "「昔涟 想要干什么,我审查后批准了」" {
t.Fatalf("小字 = %q, want design/11 §4 的固定文案", frame.Data.Explain)
}
if frame.Data.Tool != "terminal" || frame.Data.Op != string(restrict.SensitiveExecCommand) || !frame.Data.Allowed {
t.Fatalf("小字帧字段 = %+v", frame.Data)
}
if frame.Data.Detail == "" {
t.Fatal("小字帧应带动作摘要(detail),前端可作副文本")
}
if _, err := time.Parse(time.RFC3339Nano, frame.Data.At); err != nil {
t.Fatalf("帧时间戳 %q 不是 RFC3339Nano: %v", frame.Data.At, err)
}
// ② 审计链里有行(design/11 §8:代审动作批准/拒绝各落一行)。
rows := readAuditRows(t, ctx, conn)
if len(rows) != 1 {
t.Fatalf("audit_logs 行数 = %d, want 1 (%+v)", len(rows), rows)
}
got := rows[0]
if got.who != "昔涟" || got.what != "approval.terminal" || got.result != approvalResultGrant {
t.Fatalf("审计行 = %+v, want {昔涟 approval.terminal granted}", got)
}
}
// TestRecordApproval_DeniedLandsAsDeniedRow 锁定拒绝路径同样落一行
// (result=denied),且小字换成「我审查后拒绝了」。
func TestRecordApproval_DeniedLandsAsDeniedRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv, url, jwt, conn := newApprovalEnv(t)
wsConn := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner))
gate := restrict.NewGate(&approvalReviewer{allowed: false},
func(context.Context) (string, string) { return "web", "昔涟" })
decision := gate.Admit(ctx, "terminal", map[string]any{"command": "rm -rf /"})
if decision.Allow {
t.Fatalf("代审拒绝却放行: %+v", decision)
}
srv.RecordApproval(ctx, *decision.Approval)
if typ, raw := readWire(t, wsConn, "denied notice"); typ != wsApprovalNotice {
t.Fatalf("frame type = %q, want %q (%s)", typ, wsApprovalNotice, raw)
}
rows := readAuditRows(t, ctx, conn)
if len(rows) != 1 || rows[0].result != approvalResultDeny {
t.Fatalf("审计行 = %+v, want 一行 result=denied", rows)
}
}
// TestRecordApproval_NoticeReachesOwnerNotViewer 锁定 design/11 §7.1 的帧门:
// 代审小字是**内容帧**(带角色名与动作),因此只有 owner/admin 收得到,
// viewer(只读监控档)收不到——新帧默认 fail-closed,未进状态机信号白名单。
func TestRecordApproval_NoticeReachesOwnerNotViewer(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv, url, jwt, _ := newApprovalEnv(t)
viewer := dialWS(t, url, signAny(t, jwt, "viewer", auth.RoleViewer))
t.Cleanup(func() { viewer.Close() })
owner := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner))
waitConnCount(t, srv, 2)
srv.RecordApproval(ctx, restrict.ApprovalEvent{
SessionID: "web",
Actor: "昔涟",
Tool: "terminal",
Op: restrict.SensitiveExecCommand,
Detail: "terminal echo hi",
Explain: "「昔涟 想要干什么,我审查后批准了」",
Allowed: true,
At: time.Now(),
})
if typ, raw := readWire(t, owner, "owner notice"); typ != wsApprovalNotice {
t.Fatalf("owner 未收到小字帧: %q (%s)", typ, raw)
}
// viewer 侧:本就不该有帧。给一个短窗口,任何帧都算失败
// (连接在读超时后会失效,所以这必须是该连接的最后一次读)。
_ = viewer.SetReadDeadline(time.Now().Add(wsRoleGateNoFrameWait))
if _, msg, err := viewer.ReadMessage(); err == nil {
t.Fatalf("viewer 收到了代审小字帧,违反 design/11 §7.1: %s", msg)
}
}
// auditRow 是 audit_logs 的一行(who/what/result)。
type auditRow struct {
who string
what string
result string
}
// readAuditRows 读取 audit_logs 全部行(用例只写很少几行,无需分页)。
func readAuditRows(t *testing.T, ctx context.Context, conn *sql.DB) []auditRow {
t.Helper()
rows, err := conn.QueryContext(ctx, `SELECT who, what, result FROM audit_logs ORDER BY id`)
if err != nil {
t.Fatalf("query audit_logs: %v", err)
}
defer rows.Close()
out := make([]auditRow, 0, 2)
for rows.Next() {
var r auditRow
if err := rows.Scan(&r.who, &r.what, &r.result); err != nil {
t.Fatalf("scan audit_logs: %v", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate audit_logs: %v", err)
}
return out
}