관리자만 쓸 수 있는 기능을 Blade 에서 @if($isAdmin) 으로 버튼을 숨기는 것으로 막고 있었다. 버튼은 안 보이지만 DELETE /admin/products/{id} 를 직접 부르면 그대로 실행됐다.
Table of contents
Open Table of contents
원인 — 화면만 막고 서버는 안 막은 상태
화면에서 안 보이는 것과 실행할 수 없는 것은 다른 사실인데 그 구분이 코드에 반영돼 있지 않았다. 화면은 사용성을 위한 것이고 접근 통제는 서버에서 이뤄져야 한다.
한 겹으로 막으면 그 한 겹이 뚫릴 때 전부 뚫린다. 그래서 어느 층에서 무엇을 막을지를 먼저 정했다.
조치 — 층마다 막되 정하는 범위
화면과 라우팅과 컨트롤러와 서비스와 데이터베이스 다섯 층이 후보였고 전부에 넣지는 않았다. 두 가지 방법이 있었는데 다섯 층 전부에 넣는 것과 화면과 라우팅과 서비스 셋에만 넣는 것이다. 뒤쪽을 골랐다. 이유는 컨트롤러는 라우팅의 can: 미들웨어와 중복이고 데이터베이스 계정 분리는 운영 부담이 커서다. 대신 데이터베이스 층에서는 아무것도 막지 못하는 것을 감수했다.
Laravel 공식 문서대로 라우트에는 can: 미들웨어를 걸고 서비스에서는 Gate::allows 를 다시 부른다. 겹겹으로 막는다는 말이 모든 층에 넣는다는 뜻은 아니었다.
배경 — 중복이 곧 겹겹인 이유
라우팅과 서비스에서 같은 확인을 두 번 하는 것이 중복으로 보이지만 그 중복이 방어의 실체였다. routes/web.php 에서 미들웨어를 실수로 빠뜨려도 서비스가 막고 서비스를 artisan 명령에서 불러도 막힌다.
한 곳에서만 확인하면 그 한 곳을 안 거치는 경로가 생겼을 때 방어가 통째로 사라진다. 두 번 확인하는 비용이 작으므로 그 중복을 감수하는 쪽이 맞았다.
검증 — 층마다 다른 것을 보게 하기
전부 같은 확인을 하면 같은 실수를 층마다 반복하게 된다. 그래서 서비스 층에서는 ProductPolicy 로 권한만이 아니라 seller_id 대상 범위까지 보게 했다.
관리자여도 다른 조직의 대상은 못 건드리게 하니 한 층이 뚫려도 다른 층이 잡는 구조가 됐다. 각 층을 하나씩 건너뛰는 PHPUnit 시험도 만들어서 라우팅을 안 거치고 서비스를 직접 불렀을 때 AuthorizationException 이 나는지 확인했다.
주의 — 두께와 조회 횟수
되돌리기 어려운 동작일수록 두껍게 막아서 조회는 권한 확인만 하고 전체 삭제는 권한 확인과 기록과 확인 절차와 다른 관리자의 승인까지 요구했다. 막는 것과 별개로 권한 없는 시도를 audit_logs 에 남기니 며칠 만에 몇 건이 나왔고 그중 하나는 확인이 필요한 것이었다.
층을 늘리니 한 요청에 같은 roles 조회가 여러 번 나가는 문제가 생겼다. 한 요청 안에서는 값이 안 바뀌므로 once() 로 요청 단위로 담아 두게 해서 조회는 한 번으로 줄였는데 막는 층을 늘리는 것과 조회를 늘리는 것은 별개의 문제였다.
정리
- 화면에서 버튼을 숨기는 것은 접근을 막는 것이 아니다
- 한 겹으로 막으면 그 한 겹이 뚫릴 때 전부 뚫린다
- 겹겹으로 막는다는 것이 모든 층에 넣는다는 뜻은 아니다
- 같은 확인의 중복이 방어의 실체가 된다
- 층마다 다른 것을 보게 해야 같은 실수가 반복되지 않는다
- 각 층을 건너뛰는 시험으로 실제로 막히는지 확인한다
- 되돌리기 어려운 동작일수록 두껍게 막는다
- 막는 층을 늘리는 것과 조회를 늘리는 것은 별개다