이건 누가 눌러도 되나 — 버튼을 숨기는 것은 권한이 아니다

6 분
목표화면 하나를 두 사람이 연다. 취소 버튼은 둘 다에게 보인다. 알바가 누르면 서버가 거절하고, 거절이 장부에 남는다. 화면 파일은 같은 파일이고, 그 해시를 로그에 찍어 뒀다.

매장 포스에 "매출 취소" 버튼이 있다. 점장만 눌러야 한다.

보통 이렇게 한다. 로그인한 사람이 알바면 그 버튼을 안 그린다.

그리고 그게 권한 처리라고 부른다. 아니다.

안 그리는 것으로는 아무것도 못 막는다

버튼을 안 그리는 게 막는 것은 그 버튼을 눌러서 요청이 나가는 경로 하나뿐이다. 요청은 다른 데서도 나갈 수 있다. 화면 정의를 고칠 수도 있고, 아예 화면 없이 도구를 부를 수도 있다. 화면은 클라이언트고, 클라이언트는 남의 것이다.

이 샘플은 그래서 반대로 만들었다. 버튼을 둘 다에게 보여 준다.

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