これは誰が押していいのか — ボタンを隠すことは権限ではない

6 分
目標画面ひとつを二人が開く。取消ボタンは二人ともに見える。アルバイトが押すとサーバーが拒否し、拒否が帳簿に残る。画面ファイルは同じファイルであり、そのハッシュをログに焼いた。

店舗のPOSに「売上取消」ボタンがある。店長だけが押すべきものだ。

普通はこうする。ログインした人がアルバイトなら、そのボタンを描かない。

そしてそれを権限処理と呼ぶ。違う。

描かないことでは何も止められない

ボタンを描かないことが止めるのは、そのボタンを押してリクエストが出る経路ひとつだけだ。リクエストは他からも出せる。画面定義を直すこともできるし、そもそも画面なしで道具を呼ぶこともできる。画面はクライアントであり、クライアントは他人のものだ。

だからこのサンプルは逆に作った。ボタンを二人ともに見せる。

STAFF でサインイン — 4 sales · $75.00 takings。ボタンは両方そのまま表示され、till が断った回数が 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; }

画面を隠さないと使い勝手が悪くならないか

悪くなる。押せないボタンが見えるのは良いUIではない。

ところがその二つは別の層の問題だ。 隠していい — 隠すことを権限処理と呼びさえしなければ。

この順序が正しい。

層 やること なければ
道具 拒否して記録する 誰でもやる
画面 押せないものを薄くするか隠す 押してみて分かる

上がなければ下は飾りだ。 下がなければ上は不親切なだけだ。不親切なのと穴が開いているのと、どちらを先に直すかは順序が決まっている。

このサンプルが下をやらなかったのはそのためだ。上だけを見せるために。

このサンプルの範囲

認証がない。 役割が実行引数で入ってくる。実際にはその場所にログイン・トークン・証明書がなければならず、その部分がこのサンプルで最大の空白だ。 セッションに役割が付くという構造を見せただけで、その役割が本物かを確認する仕事はしていない。

役割が二つだけだ。 実際の店舗はアルバイト・店長・本部・精算担当に分かれ、道具ごとに違うかかり方をする。ここでは _managerOnly の集合ひとつだ。

時間・金額の条件がない。「50 ドル以下ならアルバイトも取消可」のような規則が現実にははるかによくあるのに、入れていない。

画面側の配慮もしていない。 上で「隠していい」と書いておきながら、このサンプルは隠していない。主張を見せるためにわざとそうした。

一行

権限は画面が何を描くかではなく、道具が何をするかだ。

画面を描き直すのはファイルひとつを直せばよく、それはこのプラットフォームでとりわけ簡単だ。簡単なのが問題だ。 直しやすい層に規則を置けば、規則もそれだけ簡単に消える。

練習課題

同じ予約を続けて二回送ってください。二回目で予約がもうひとつ作られないことを示し、どちら側がそれを保証したかを説明してください。

関連記事Who May Press This — Hiding a Button Is Not Authorisation