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 触发代审 + 审计有行有端到端断言
This commit is contained in:
1 parent
ed5bd92e1b
commit
63236c9b00
20 files changed
+1709
-24
No files matched your search
@@ -0,0 +1,70 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
)
|
||||
|
||||
// 本文件是 design/11 §4「敏感操作代审」的对外侧实现(裁决 13.4.3):
|
||||
// loop 的工具准入钩子判定出「该敏感操作已获(或未获)代审」后,组合根
|
||||
// 把记录交给这里——① 落审计链 audit_logs(who/what/when/result),
|
||||
// ② 向 owner/admin 连接广播小字帧。判定与代审本身在
|
||||
// internal/capability/restrict(Gate);本文件只管「记录 + 呈现」。
|
||||
|
||||
// wsApprovalNotice 是主代理代审小字的 WS 事件类型(design/11 §4)。
|
||||
// 它携带内容(角色名、动作、文案),因此 design/11 §7.1 的帧门默认
|
||||
// fail-closed:只投递给 owner + admin 连接,viewer 收不到,也不进那 7 个
|
||||
// 状态机信号白名单。
|
||||
const wsApprovalNotice = "approval_notice"
|
||||
|
||||
// 代审在审计链里的 who/what/result 口径(design/11 §8):
|
||||
// who = 想动手的角色名,what = "approval.<工具名>",result = granted|denied。
|
||||
// 代审动作批准/拒绝**各落一行**,因此前缀固定、结果二分。
|
||||
const (
|
||||
approvalAuditPrefix = "approval."
|
||||
approvalResultGrant = "granted"
|
||||
approvalResultDeny = "denied"
|
||||
)
|
||||
|
||||
// RecordApproval 记录一次主代理代审(design/11 §4 §8;裁决 13.4.3)。
|
||||
//
|
||||
// ① 审计:把 who(角色名)/ what(approval.<工具名>)/ when / result
|
||||
// (granted / denied)追加进 audit_logs —— 代审动作批准与拒绝各落一行,
|
||||
// 供管理面板「可观测性 → 审计」与通知 feed 查询。
|
||||
// ② 前端小字:广播 approval_notice 帧,携带 design/11 §4 的固定文案
|
||||
// 「xxx(角色名) 想要干什么,我审查后批准了」(explain),以及工具名、
|
||||
// 操作类别与动作摘要(detail,客户端可作副文本或忽略)。
|
||||
//
|
||||
// 审计写入用 context.WithoutCancel:代审发生在一次 Run 内部,而 Run 的 ctx
|
||||
// 可能已被取消(用户中断)——已经发生的代审仍然必须留痕。审计存储坏掉时
|
||||
// gateway.audit 只记一条日志,不影响代审已经成立的结论。
|
||||
//
|
||||
// 方法对 nil 接收者安全(组合根在网关构建完成前调用它时不会 panic)。
|
||||
func (s *Server) RecordApproval(ctx context.Context, ev restrict.ApprovalEvent) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result := approvalResultDeny
|
||||
if ev.Allowed {
|
||||
result = approvalResultGrant
|
||||
}
|
||||
s.audit(context.WithoutCancel(ctx), ev.Actor, approvalAuditPrefix+ev.Tool, result)
|
||||
s.hub.broadcast(wsEvent{
|
||||
Type: wsApprovalNotice,
|
||||
SessionID: ev.SessionID,
|
||||
Data: map[string]any{
|
||||
"actor": ev.Actor,
|
||||
"tool": ev.Tool,
|
||||
"op": string(ev.Op),
|
||||
"detail": ev.Detail,
|
||||
"explain": ev.Explain,
|
||||
"allowed": ev.Allowed,
|
||||
"at": ev.At.UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件锁定裁决 1.3/10.4.2 的管理面一侧:面板的「工具策略」保存
|
||||
// (POST /api/v1/admin/plugins/policy)真的**先落库再改内存**——重启后策略仍在。
|
||||
// 落盘载体是 tool_policies 表(迁移 0012_tool_policy),由 registry 的
|
||||
// PolicyStore 钩子写入;这里用真实 SQLite 库走完整 HTTP 路径。
|
||||
|
||||
// policyEnv 接线一个「策略已接存储」的能力注册表的网关,返回服务端地址、
|
||||
// JWT 服务与库连接(用例直接查 tool_policies 断言落盘)。
|
||||
func policyEnv(t *testing.T) (string, *auth.JWTService, *sql.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), log)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if err := db.MigrateUp(ctx, conn, log); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
store, err := capreg.NewSQLitePolicyStore(conn, log)
|
||||
if err != nil {
|
||||
t.Fatalf("policy store: %v", err)
|
||||
}
|
||||
reg := capreg.New()
|
||||
if err := reg.AttachPolicyStore(ctx, store); err != nil {
|
||||
t.Fatalf("AttachPolicyStore: %v", err)
|
||||
}
|
||||
if err := reg.RegisterTool(stubTool{}); err != nil {
|
||||
t.Fatalf("register stub tool: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Registry: reg})
|
||||
return ts.URL, jwt, conn
|
||||
}
|
||||
|
||||
// TestAdminSetPolicyPersistsToToolPolicies 锁定「面板保存 → 真落库」:
|
||||
// 走真实 HTTP 端点写入后,tool_policies 里有对应的行。
|
||||
func TestAdminSetPolicyPersistsToToolPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
url, jwt, conn := policyEnv(t)
|
||||
owner := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, "POST", url+"/api/v1/admin/plugins/policy",
|
||||
map[string]any{"name": "echo", "allowInGroup": true, "allowInStranger": false}, owner)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("POST policy status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
var (
|
||||
group bool
|
||||
stranger bool
|
||||
)
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT allow_in_group, allow_in_stranger FROM tool_policies WHERE name = 'echo'`).
|
||||
Scan(&group, &stranger); err != nil {
|
||||
t.Fatalf("tool_policies 里没有 echo 的策略行(未落盘?): %v", err)
|
||||
}
|
||||
if !group || stranger {
|
||||
t.Fatalf("落盘策略 = {group:%v stranger:%v}, want {true false}", group, stranger)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSetPolicySurvivesRestart 跨「重启」验证:写完策略后另外构造一个
|
||||
// 注册表重新 Load,标记仍在(这正是裁决 1.3 要求消灭的「重启即失」)。
|
||||
func TestAdminSetPolicySurvivesRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
url, jwt, conn := policyEnv(t)
|
||||
owner := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
if resp, body := doReq(t, "POST", url+"/api/v1/admin/plugins/policy",
|
||||
map[string]any{"name": "echo", "allowInGroup": false, "allowInStranger": true}, owner); resp.StatusCode != 200 {
|
||||
t.Fatalf("POST policy status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 模拟重启:新注册表 + 新 store,只有 Load 一条路能拿到刚才的标记。
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
store, err := capreg.NewSQLitePolicyStore(conn, log)
|
||||
if err != nil {
|
||||
t.Fatalf("policy store (2): %v", err)
|
||||
}
|
||||
rebooted := capreg.New()
|
||||
if err := rebooted.AttachPolicyStore(ctx, store); err != nil {
|
||||
t.Fatalf("AttachPolicyStore (2): %v", err)
|
||||
}
|
||||
if err := rebooted.RegisterTool(stubTool{}); err != nil {
|
||||
t.Fatalf("RegisterTool (2): %v", err)
|
||||
}
|
||||
pol, err := rebooted.Policy("echo")
|
||||
if err != nil {
|
||||
t.Fatalf("Policy (2): %v", err)
|
||||
}
|
||||
if pol.AllowInGroup || !pol.AllowInStranger {
|
||||
t.Fatalf("重启后策略 = %+v, want {group:false stranger:true}", pol)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user