一次“只加一个字段”的越权读取
「回声」是一个带个人主页的社区产品。服务端早期只有三个业务包:
internal/account:账号、外部身份绑定、登录会话。internal/profile:昵称、头像、简介、可见性和注销导出。internal/bff:按 iOS、Android、Web、小程序组装页面数据。
某天产品要在个人主页显示“注册天数”。BFF 的负责人发现 account 库里已经有 created_at,于是在 BFF 的查询里直接 join 了账号表。代码评审只看了 SQL,觉得“只读一个时间字段,不会出事”,合并上线。
两周后,一个被对方拉黑的用户打开个人主页,仍然看到了对方的手机号尾号。问题不在这次 join 本身,而在于 BFF 从此绕过了 profile 的可见性过滤:手机号本来只允许本人和客服读取,BFF 却把它当成“公开资料”拼进了响应。更麻烦的是,修复时没人说得清还有多少条查询走了同一条捷径。
这次事故暴露的不是某个字段忘了脱敏,而是三个模块之间从来没有约定“谁拥有什么数据、谁只能通过什么方式读取”。目录分开了,所有权没有分开。
模块边界是数据所有权,不是目录名
把三个模块放在三个目录里,只能说明代码放在哪,不能说明谁能改哪张表、谁能读哪些字段。真正可执行的边界至少要回答四个问题:这个模块拥有什么数据,它依赖谁,它对外暴露什么,它绝对不能做什么。
| 模块 | 拥有的数据 | 允许依赖 | 对外暴露 | 明确禁止 |
|---|---|---|---|---|
account | 账号 ID、外部身份、凭据、会话与设备 | 无业务上游 | 认证结果、会话撤销、身份绑定 | 读取资料可见性、拼装页面 |
profile | 昵称、头像、简介、可见性、注销与导出 | account 的账号 ID | 按访问者裁剪后的资料、删除与导出任务 | 直接签发会话、按端拼装响应 |
bff | 各端页面契约、字段白名单、端能力 | account、profile 的公开接口 | 面向具体端的聚合响应 | 直接读写账号库或资料库 |
audit | 管理操作与敏感读取记录 | 各模块的审计事件 | 审计查询 | 反向调用业务写路径 |
这张表最重要的不是“拥有”一列,而是“明确禁止”一列。只写允许什么,边界会在下一次“就加一个字段”时被绕过;把禁止项写出来,评审才有拒绝的依据。
“BFF 是否可以直接读账号库”这个问题,答案取决于读的是什么。如果读的是账号模块已经承诺的公开投影,比如账号 ID 和创建时间,那应该由 account 提供一个稳定的只读接口,BFF 调用接口而不是表。如果读的是凭据、会话、身份详情,那不只是耦合问题,而是越权问题:BFF 没有能力判断这些字段该不该出现在当前端的响应里。
profile 依赖 account 的账号 ID,但不依赖账号库的表结构。账号 ID 是契约,表结构是实现。依赖契约时,账号模块可以换存储、拆库、加索引;依赖表结构时,任何一次迁移都要先问 BFF 能不能一起发版。
bff 的职责是“同一份事实,按端裁剪和组装”。它拥有响应契约,不拥有业务事实。把创建时间缓存进 BFF 看起来省了一次调用,实际上制造了第二份事实:账号注销后 BFF 还在返回旧数据,资料可见性变更后 BFF 的缓存不知道。BFF 可以做短 TTL 的响应缓存,但不能把业务数据复制成自己的主数据。
还有一条经常被忽略:模块之间的依赖必须无环。profile 需要账号 ID,bff 需要资料,account 不应反过来调用 bff 发通知。一旦出现环,启动顺序、发布顺序和故障传播都会变成猜谜。环不一定在代码 import 里出现,也可能藏在“账号模块调用资料接口补昵称”这种运行时调用里。
失败场景:共享 models 包让边界名存实亡
比 BFF 直接读库更常见的退化,是三个模块共用一个 internal/models 包。最初只是为了复用 User 结构体,后来变成所有查询的集散地:
models.User同时带账号字段、资料字段、会话字段和 BFF 展示字段。- 资料模块改一个字段名,账号模块和 BFF 一起重编译,发布被迫同步。
- 可见性判断被复制到 BFF 的模板 helper 里,两份规则逐渐不一致。
- 账号库想做一次字段拆分,发现 BFF 的 SQL 直接引用了旧列名。
后果不是“编译慢”,而是三件更严重的事。第一,任何模块都无法独立发布:一个资料字段的改名会阻塞账号模块的热修复。第二,权限判断失去唯一入口:资料模块改了拉黑规则,BFF 的旧 helper 还在按好友关系放行。第三,故障定位失去边界:线上返回了不该返回的字段,无法判断是资料裁剪错了,还是 BFF 自己查了库。
共享 models 包最危险的地方,是它让越界变得“类型安全”。编译器不会阻止 BFF 读取 User.Phone,因为字段就在那里。目录边界只能靠人自觉,而人总会在截止日期前选择更短的那条路。
修复这类退化,通常要做三步:先把 models 里的字段按所有者拆回各自模块;再把跨模块读取改成显式接口,接口只返回调用方真正需要的投影;最后在启动时校验模块图的依赖声明,让“新增一条反向依赖”在测试阶段就失败,而不是等线上事故。
用 Validate 与 Order 固定模块图
模块图可以先写成数据,再让代码校验。下面的实现把模块名、拥有数据和依赖声明放在一起,提供两个能力:Validate 检查重复、缺失依赖和环,Order 返回确定的拓扑序。
// file: internal/baseline/module.go
// tier: L1
// verify: go test ./internal/baseline/
package baseline
import (
"fmt"
"sort"
"strings"
)
// Module 是服务端的一个业务模块。Owns 是它拥有的数据表或资源,
// DependsOn 是它调用或读取的上游模块名。
type Module struct {
Name string
Owns []string
DependsOn []string
}
// Validate 检查模块图的基本不变量:名字唯一、依赖存在、没有环。
func Validate(modules []Module) error {
index, err := indexModules(modules)
if err != nil {
return err
}
for _, m := range modules {
for _, dep := range m.DependsOn {
if _, ok := index[dep]; !ok {
return fmt.Errorf("baseline: module %q depends on unknown module %q", m.Name, dep)
}
}
}
if cycle := findCycle(modules, index); len(cycle) > 0 {
return fmt.Errorf("baseline: dependency cycle: %s", strings.Join(cycle, " -> "))
}
return nil
}
// Order 返回确定的拓扑序。层级按最长依赖链计算,同层按名字升序,
// 因此同一张模块图无论输入顺序如何都得到同一个结果。
func Order(modules []Module) ([]string, error) {
if err := Validate(modules); err != nil {
return nil, err
}
index, err := indexModules(modules)
if err != nil {
return nil, err
}
level := make(map[string]int, len(modules))
var depth func(name string) int
depth = func(name string) int {
if got, ok := level[name]; ok {
return got
}
best := 0
for _, dep := range index[name].DependsOn {
if d := depth(dep) + 1; d > best {
best = d
}
}
level[name] = best
return best
}
names := make([]string, 0, len(modules))
for _, m := range modules {
names = append(names, m.Name)
depth(m.Name)
}
sort.Slice(names, func(i, j int) bool {
if level[names[i]] != level[names[j]] {
return level[names[i]] < level[names[j]]
}
return names[i] < names[j]
})
return names, nil
}
// indexModules 建立名字到模块的索引。空名字和重复名字都在这里被拒绝,
// 避免后续拓扑计算拿到含义不明的节点。
func indexModules(modules []Module) (map[string]Module, error) {
index := make(map[string]Module, len(modules))
for _, m := range modules {
if strings.TrimSpace(m.Name) == "" {
return nil, fmt.Errorf("baseline: module name is required")
}
if _, dup := index[m.Name]; dup {
return nil, fmt.Errorf("baseline: duplicate module %q", m.Name)
}
index[m.Name] = m
}
return index, nil
}
// findCycle 用三色 DFS 找一条环。栈中仍在灰态的节点就是环上的节点,
// 返回时首尾同名,方便错误信息直接读出环路。
func findCycle(modules []Module, index map[string]Module) []string {
const (
white = 0
gray = 1
black = 2
)
color := make(map[string]int, len(modules))
stack := make([]string, 0, len(modules))
var dfs func(name string) []string
dfs = func(name string) []string {
color[name] = gray
stack = append(stack, name)
deps := append([]string(nil), index[name].DependsOn...)
sort.Strings(deps)
for _, dep := range deps {
switch color[dep] {
case gray:
start := 0
for i, n := range stack {
if n == dep {
start = i
break
}
}
cycle := append([]string(nil), stack[start:]...)
return append(cycle, dep)
case white:
if c := dfs(dep); len(c) > 0 {
return c
}
}
}
stack = stack[:len(stack)-1]
color[name] = black
return nil
}
for _, m := range modules {
if color[m.Name] == white {
if c := dfs(m.Name); len(c) > 0 {
return c
}
}
}
return nil
}
Validate 不尝试“修复”图,只负责拒绝。重复模块名会让依赖指向两个节点,缺失依赖会让启动顺序无法确定,环会让拓扑序不存在;这三类问题都必须在启动前失败,而不是在运行时靠重试绕开。
Order 先用最长依赖链计算层级,再按“层级升序、同层名字升序”排序。这样 account 和 audit 这类无依赖模块总在同一层,名字小的先出现;profile 在 account 之后,bff 在 profile 之后。同一张图无论模块切片怎么打乱,输出都一致,测试和部署脚本才能稳定比较。
findCycle 的三色标记还有一个实际好处:错误信息会给出完整环路,例如 account -> bff -> profile -> account,而不是只说“存在循环依赖”。值班的人可以直接按环路找到那几条反向调用,不需要自己把图再画一遍。
测试:重复、缺依赖与环
测试要覆盖的不只是“正常图能通过”,更要覆盖每一种让边界失效的输入。
// file: internal/baseline/module_test.go
// tier: L1
// verify: go test ./internal/baseline/
package baseline
import (
"reflect"
"strings"
"testing"
)
func TestValidateAcceptsAcyclicGraph(t *testing.T) {
modules := []Module{
{Name: "account"},
{Name: "profile", DependsOn: []string{"account"}},
{Name: "bff", DependsOn: []string{"account", "profile"}},
}
if err := Validate(modules); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestValidateRejectsDuplicateModule(t *testing.T) {
err := Validate([]Module{{Name: "account"}, {Name: "account"}})
if err == nil || !strings.Contains(err.Error(), "duplicate module") {
t.Fatalf("Validate() error = %v, want duplicate module", err)
}
}
func TestValidateRejectsMissingDependency(t *testing.T) {
err := Validate([]Module{{Name: "bff", DependsOn: []string{"profile"}}})
if err == nil || !strings.Contains(err.Error(), "unknown module") {
t.Fatalf("Validate() error = %v, want unknown module", err)
}
}
func TestValidateReportsNodesOnCycle(t *testing.T) {
err := Validate([]Module{
{Name: "bff", DependsOn: []string{"profile"}},
{Name: "profile", DependsOn: []string{"account"}},
{Name: "account", DependsOn: []string{"bff"}},
})
if err == nil {
t.Fatal("Validate() error = nil, want cycle")
}
for _, name := range []string{"account", "bff", "profile"} {
if !strings.Contains(err.Error(), name) {
t.Fatalf("cycle error %q does not mention %q", err, name)
}
}
if strings.Count(err.Error(), "->") != 3 {
t.Fatalf("cycle error %q does not close the loop", err)
}
}
func TestOrderSortsSameLayerByName(t *testing.T) {
modules := []Module{
{Name: "bff", DependsOn: []string{"profile", "account"}},
{Name: "profile", DependsOn: []string{"account"}},
{Name: "account"},
{Name: "audit"},
}
got, err := Order(modules)
if err != nil {
t.Fatalf("Order() error = %v", err)
}
want := []string{"account", "audit", "profile", "bff"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Order() = %v, want %v", got, want)
}
}
func TestOrderIsRepeatableAcrossInputOrder(t *testing.T) {
forward := []Module{
{Name: "zulu"},
{Name: "alpha", DependsOn: []string{"zulu"}},
{Name: "bravo", DependsOn: []string{"zulu"}},
}
backward := []Module{forward[2], forward[1], forward[0]}
first, err := Order(forward)
if err != nil {
t.Fatalf("Order(forward) error = %v", err)
}
second, err := Order(backward)
if err != nil {
t.Fatalf("Order(backward) error = %v", err)
}
want := []string{"zulu", "alpha", "bravo"}
if !reflect.DeepEqual(first, want) || !reflect.DeepEqual(second, want) {
t.Fatalf("orders = %v and %v, want %v", first, second, want)
}
}
func TestOrderEmptyGraph(t *testing.T) {
got, err := Order(nil)
if err != nil {
t.Fatalf("Order(nil) error = %v", err)
}
if len(got) != 0 {
t.Fatalf("Order(nil) = %v, want empty", got)
}
}
TestValidateReportsNodesOnCycle 不只检查“返回了错误”,还逐个确认环路里的三个名字都出现在错误信息中,并且 -> 出现三次。少了任何一个节点,值班人员就无法从日志还原环。
TestOrderIsRepeatableAcrossInputOrder 把同一组模块正序和逆序各跑一次,要求结果完全相同。拓扑序如果依赖 map 遍历顺序,今天和明天可能不同,部署脚本的 diff 会变成噪声,灰度批次也会莫名变化。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/baseline/ -count=1 -v -run 'TestValidate|TestOrder'
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateAcceptsAcyclicGraph
--- PASS: TestValidateAcceptsAcyclicGraph (0.00s)
=== RUN TestValidateRejectsDuplicateModule
--- PASS: TestValidateRejectsDuplicateModule (0.00s)
=== RUN TestValidateRejectsMissingDependency
--- PASS: TestValidateRejectsMissingDependency (0.00s)
=== RUN TestValidateReportsNodesOnCycle
--- PASS: TestValidateReportsNodesOnCycle (0.00s)
=== RUN TestOrderSortsSameLayerByName
--- PASS: TestOrderSortsSameLayerByName (0.00s)
=== RUN TestOrderIsRepeatableAcrossInputOrder
--- PASS: TestOrderIsRepeatableAcrossInputOrder (0.00s)
=== RUN TestOrderEmptyGraph
--- PASS: TestOrderEmptyGraph (0.00s)
PASS
ok webapp/internal/baseline 0.226s
0.226s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。-run 'TestValidate|TestOrder' 只匹配模块图相关的测试,不会执行同包的端到端和基线测试。
小结
账号、资料和 BFF 的边界,不是三个目录,而是三组可执行的所有权声明:账号拥有身份与会话,资料拥有可见性与个人信息,BFF 拥有按端组装的响应契约。BFF 可以读账号模块公开的投影,但不能直接读账号库;资料模块可以依赖账号 ID,但不能依赖账号表结构。
模块图要无环、依赖要存在、名字要唯一,拓扑序要稳定。把这些规则写成 Validate 和 Order,边界才会在每次构建和每次新增依赖时被检查,而不是等一次越权读取把问题送到用户面前。
下一节把视角从模块图拉到用户旅程:单测全绿不代表注册、登录、改资料、踢旧设备这条链真的能走通。
阅读导航:上一节:目录与学习路径 · 下一节:13.2 端到端用例与测试 。
常见错误与边界
- 把目录当边界。代码分包但共用一张表或一个
models包,边界只存在于目录名里。 - BFF 直接 join 账号库。今天是只读一个时间字段,明天就会有人加手机号。
- 用“只读”为越权开脱。读凭据、会话和敏感资料不是耦合问题,是权限问题。
- 依赖表结构而不是契约。账号库一迁移,所有调用方被迫同步发布。
- 允许模块图出现环。环会让启动顺序、发布顺序和故障传播都无法独立判断。
- 拓扑序依赖 map 遍历。同一张图每次输出不同,部署 diff 和灰度批次都失去意义。
- 只校验“能启动”,不校验依赖声明。新增反向调用不会失败,直到线上出现循环调用。
- 把
Owns当装饰。拥有关系如果不进入评审和测试,就只是注释。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。