이건 누가 눌러도 되나 — 버튼을 숨기는 것은 권한이 아니다
6 분매장 포스에 "매출 취소" 버튼이 있다. 점장만 눌러야 한다.
보통 이렇게 한다. 로그인한 사람이 알바면 그 버튼을 안 그린다.
그리고 그게 권한 처리라고 부른다. 아니다.
안 그리는 것으로는 아무것도 못 막는다
버튼을 안 그리는 게 막는 것은 그 버튼을 눌러서 요청이 나가는 경로 하나뿐이다. 요청은 다른 데서도 나갈 수 있다. 화면 정의를 고칠 수도 있고, 아예 화면 없이 도구를 부를 수도 있다. 화면은 클라이언트고, 클라이언트는 남의 것이다.
이 샘플은 그래서 반대로 만들었다. 버튼을 둘 다에게 보여 준다.

알바 화면이다. "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] 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 이하는 알바도 취소 가능" 같은 규칙이 현실에서는 훨씬 흔한데, 안 넣었다.
화면 쪽 배려도 안 했다. 위에서 "숨겨도 된다"고 써 놓고 이 샘플은 안 숨겼다. 주장을 보이려고 일부러 그랬다.
한 줄
권한은 화면이 무엇을 그리느냐가 아니라, 도구가 무엇을 하느냐다.
화면을 다시 그리는 데는 파일 하나 고치면 되고, 그건 이 플랫폼에서 특히 쉽다. 쉽다는 게 문제다. 고치기 쉬운 층에 규칙을 두면 규칙도 그만큼 쉽게 없어진다.
확인 과제
같은 예약을 연달아 두 번 보내 보세요. 두 번째가 예약을 하나 더 만들지 않는다는 것을 보이고, 어느 쪽이 그것을 보장했는지 설명하세요.