这个谁能按 — 把按钮藏起来不是权限

6 分钟
目标两个人打开同一个画面。撤销按钮对两人都可见。店员按下去时服务器拒绝,而拒绝被记进了台账。画面文件是同一个文件,我们把它的哈希打进了日志。

门店 POS 上有个「撤销销售」按钮。只有店长该按。

一般是这么做的:如果登录的人是店员,就不画那个按钮。

然后管这叫权限处理。它不是。

不画它什么也拦不住

不画按钮拦住的只是**「按那个按钮把请求发出去」这一条路径。** 请求可以从别处发出。画面定义可以改,甚至完全不要画面直接调工具也行。画面是客户端,而客户端是别人的。

所以这个样例反着来。把按钮给两个人都看。

以 STAFF 登录 —— 4 sales · $75.00 takings。两个按钮照旧显示,收银台拒绝的次数是 2。页脚是 "Voids and drawer opens are the manager's"

这是店员的画面。「Void last sale」和「Open drawer」都原样在那儿。按得下去。

按下去是这样。

[staff] sale.ring   -> "rang 3000"                        (sales=4 voided=0 refused=0)
[staff] sale.void   -> "sale.void is not yours to press"  (sales=4 voided=0 refused=1)
[staff] drawer.open -> "drawer.open is not yours to press"(sales=4 voided=0 refused=2)

卖是卖成了,撤销没成。 不是画面拦住的,是服务器没做。

店长打开同一个画面

以 MANAGER 登录 —— 同一块屏、同样两次点击。这次是 voided 1 · refused 0

[manager] sale.ring   -> "rang 3000"     (sales=4 voided=0 refused=0)
[manager] sale.void   -> "voided 3000"   (sales=3 voided=1 refused=0)
[manager] drawer.open -> "drawer opened" (sales=3 voided=1 refused=0)

同样的按钮、同样的顺序、不同的结果。

而且画面文件是同一个文件。 这正是这一篇的主张,所以测试装置把哈希打了出来。

screen ui/pages/register.json sha256:72288a2c655a — one file, both roles
the screen offers 3 buttons and shows all of them to everyone

角色不是参数

这是这个样例里唯一的设计决定。

/// Who this session is. Fixed for its lifetime, never taken from a call.
final String role;

假设 sale.void 把 role 当参数收。

sale.void { "role": "manager" }

那么任何能调用它的人都可以自称是店长。 那一刻真正在拦着的,就只剩「画面不发这个值」,于是我们又回到了「不画按钮」。

所以角色在会话打开时就定下来。在这个样例里是启动参数,在真实部署里则是那条连接所认证到的身份。

final role = args
    .firstWhere((a) => a.startsWith('--role='), orElse: () => '--role=staff')
    .substring('--role='.length);

画面不发送角色。就算想发也发不了。

验证守住这一点 — 画面定义里一旦出现角色名,就判失败。

# The screen may *display* the role it was told. It must never name one — the
# moment "manager" or "staff" appears in a screen definition, some branch is
# being decided in the wrong place.
if grep -qiE '"[^"]*(manager|staff)' register.mbd/ui/pages/register.json; then
  echo "   the screen definition names a role — the rule has leaked out of the tool"
  exit 1
fi

画面显示 {{role}}。那是把服务器告诉它的东西画出来,它不决定任何事。

拒绝是一条记录

拒绝完就结束,那只是一半。

/// Refusals go here as well as into the answer. A refusal that only the
/// person who was refused can see is not much of a control — the point of
/// writing it down is that somebody who was not standing there can read it
/// tomorrow.
final File audit;
audit log holds 2 lines: refused sale.void for role=staff | refused drawer.open for role=staff

只有被拒绝的人知道的拒绝,算不上什么管控。 得让当时不在场的人明天能读到,它才是管控。「今天有十二次试图打开钱箱」这个事实,即便那十二次全被挡住了,也是老板该知道的信息。

验证也直接去数那个文件。

AUDIT=$(wc -l < register_server/audit.log | tr -d ' ')
[ "$AUDIT" -eq 2 ] || { echo "   audit log has $AUDIT lines, expected 2"; exit 1; }

不把画面藏起来,体验不就差了吗

会差。按不了的按钮还摆在那儿,不是好界面。

可这两件事在不同的层。 藏起来没问题 — 只要别把「藏起来」叫作权限处理。

正确的顺序是这样。

层 做什么 没有它就
工具 拒绝并记录 谁都能干
画面 把按不了的变灰或隐藏 按一下才知道

没有上面那层,下面那层就是装饰。 没有下面那层,上面那层只是不体贴。不体贴和有窟窿之间,先修哪个是有定论的。

这个样例没做下面那层,就是这个缘故。为了单独把上面那层亮出来。

这个样例的范围

没有认证。 角色是通过启动参数进来的。现实中那个位置该是登录、令牌或证书,而那正是这个样例最大的空白。 它只展示了「角色挂在会话上」这个结构,并没有做「确认那个角色是不是真的」这件事。

只有两个角色。 真实门店会分成店员、店长、总部、结算负责人,而且不同工具的限制方式也不同。这里只有一个 _managerOnly 集合。

没有时间和金额条件。「50 美元以下店员也能撤销」这类规则在现实中常见得多,而我们没放进来。

画面这一侧的体贴也没做。 上面写着「藏起来没问题」,而这个样例并没有藏。是为了把主张亮出来才故意如此。

一句话

权限不是画面画了什么,而是工具做了什么。

重画一个画面只需改一个文件,而这在这个平台上尤其容易。容易正是问题。 把规则放在容易改的那一层,规则也就同样容易消失。

练习任务

连续发送两次相同的预订。证明第二次不会再生成一个预订,并说明是哪一方保证了这一点。

相关文章Who May Press This — Hiding a Button Is Not Authorisation