계정별 데이터 격리
여러 사업자가 함께 쓰는 서비스에서 한 계정의 데이터가 다른 계정에 보이지 않게 막는 서버 구조와, 그것을 매번 확인하는 자동 시험. 지점이 여러 곳인 사업자의 예약·매출 관리 서비스를 표본으로 만들었고, Cloudflare Workers·Hono·D1 위에서 돕니다.
- 시험한 API 경로
- 15개 (전부)
- 자동 시험
- 687건
- 실패
- 0건
- 시험 시간
- 1.2초
2026년 11월 11일 로컬 시험 결과 · 커밋 c5296c3
막는 것
다른 계정
404B 사업자로 로그인한 채 A 사업자의 예약·지점 아이디를 열면 404. 목록에도 섞이지 않는다.
같은 계정의 다른 지점
404지점장·직원은 소속 지점 데이터만 본다. 다른 지점 예약을 아이디로 열어도 404.
권한 없는 역할
403직원 계정은 매출과 직원 목록을 볼 수 없다. 메뉴만 숨기지 않고 서버가 403 으로 막는다.
남의 아이디를 넣은 쓰기
404예약을 만들 때 다른 계정의 지점·서비스·담당자 아이디를 섞어 보내면 만들지 않고 404.
남의 데이터는 403 이 아니라 404 로 답합니다. 그런 아이디가 있는지조차 알려 주지 않습니다.
구조
- 1
요청
쿠키에 세션 토큰만 실린다 (httpOnly)
- 2
공용 미들웨어
허용 출처 · 요청 제한 · 방문자 구분
- 3
권한 검사 한 곳
세션 → 사용자 → 계정·역할 → 경로가 요구하는 권한
- 4
계정 범위 창구
모든 조회·수정에 계정(·지점) 조건이 자동으로 붙는다
- 5
D1
행 단위 보안이 없어 위 창구로만 닿는다
- D1(SQLite)에는 PostgreSQL 의 행 단위 보안(RLS) 같은 장치가 없습니다. 빠진 조건 하나가 곧 유출이라, 계정 조건을 쿼리마다 쓰지 않고 한 함수가 붙이게 했습니다.
- 경로 코드는 D1 바인딩과 쿼리 도구를 직접 가져올 수 없습니다(lint 규칙). 권한 검사를 통과하면 그 계정 범위로 묶인 창구만 넘겨받습니다.
- 판단은 Hono 밖의 함수(서비스·데이터 계층)에 두었습니다. 프레임워크를 바꾸면 경로를 잇는 파일만 다시 씁니다.
// data/scope.ts - 계정 데이터는 모두 이 조건을 거쳐서만 읽고 쓴다
export function scopeWhere(cols: TenantColumns, scope: Scope, ...extra: (SQL | undefined)[]) {
return and(
eq(cols.account, scope.accountId),
scope.branchId && cols.branch ? eq(cols.branch, scope.branchId) : undefined,
...extra,
);
}// routes.ts - 경로는 권한 이름과 서비스 함수만 잇는다. D1 바인딩은 lint 로 막혀 있다
routes.get('/reservations/:id', guard('reservation:read'), async (c) =>
c.json(await getReservation(c.var.data, c.req.param('id'))),
);자동 시험
- 서버에 등록된 경로 목록을 시험 정의와 대조합니다. 시험 정의가 없는 경로가 새로 생기면 시험이 실패합니다.
- 같은 방문자의 A·B 사업자 계정(대표·지점장·직원)과 다른 방문자의 A 대표, 일곱 명으로 모든 경로를 부릅니다.
- 상태 코드뿐 아니라 응답 본문 전체에서 그 사람이 받으면 안 되는 아이디가 있는지 찾습니다.
- 시험이 실제로 새는 곳을 잡는지도 확인합니다. 계정 조건을 뺀 함정 경로(
GET /__trap/reservations/:id)를 시험 환경에만 붙이면 416건 중 324건을 실패로 잡고, 시험 정의가 없는 경로라는 것도 잡습니다. - Workers 런타임(workerd)과 로컬 D1 에서 돌고, 아래 표는 그 결과 파일을 그대로 보여 줍니다.
GET/sample-users공개1 / 1무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 200 200 1/1 POST/session공개3 / 3무엇을 누가 기대 결과 건수 자기 데이터 방문자 1 201 201 1/1 다른 방문자 방문자 2 201 201 1/1 로그인 없이 로그인 안 함 404 404 1/1 DELETE/session공개1 / 1무엇을 누가 기대 결과 건수 자기 데이터 B 대표 204, 이후 401 204, 이후 401, 다른 세션 200 1/1 GET/me로그인9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 자기 데이터 A 대표 200 200 1/1 자기 데이터 A 강남점 지점장 200 200 1/1 자기 데이터 A 강남점 직원 200 200 1/1 자기 데이터 B 대표 200 200 1/1 자기 데이터 B 합정점 지점장 200 200 1/1 자기 데이터 B 합정점 직원 200 200 1/1 자기 데이터 다른 방문자의 A 대표 200 200 1/1 GET/permissions로그인9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 자기 데이터 A 대표 200 200 1/1 자기 데이터 A 강남점 지점장 200 200 1/1 자기 데이터 A 강남점 직원 200 200 1/1 자기 데이터 B 대표 200 200 1/1 자기 데이터 B 합정점 지점장 200 200 1/1 자기 데이터 B 합정점 직원 200 200 1/1 자기 데이터 다른 방문자의 A 대표 200 200 1/1 GET/branches지점 보기9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 자기 데이터 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 직원 200, 자기 범위만 200, 자기 범위만 1/1 다른 계정 B 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 직원 200, 자기 범위만 200, 자기 범위만 1/1 다른 방문자 다른 방문자의 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 GET/branches/:id지점 보기25 / 25무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 자기 데이터 A 대표 200 200 3/3 자기 데이터 A 강남점 지점장 200 200 1/1 다른 지점 A 강남점 지점장 404 404 2/2 자기 데이터 A 강남점 직원 200 200 1/1 다른 지점 A 강남점 직원 404 404 2/2 다른 계정 B 대표 404 404 3/3 다른 계정 B 합정점 지점장 404 404 3/3 다른 계정 B 합정점 직원 404 404 3/3 다른 방문자 다른 방문자의 A 대표 404 404 3/3 다른 계정 A 대표 404 404 2/2 GET/services예약 보기9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 자기 데이터 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 직원 200, 자기 범위만 200, 자기 범위만 1/1 다른 계정 B 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 직원 200, 자기 범위만 200, 자기 범위만 1/1 다른 방문자 다른 방문자의 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 GET/staff직원 보기9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 권한 없는 역할 A 강남점 직원 403 403 1/1 권한 없는 역할 B 합정점 직원 403 403 1/1 자기 데이터 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 계정 B 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 방문자 다른 방문자의 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 GET/reservations예약 보기9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 자기 데이터 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 직원 200, 자기 범위만 200, 자기 범위만 1/1 다른 계정 B 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 직원 200, 자기 범위만 200, 자기 범위만 1/1 다른 방문자 다른 방문자의 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 GET/reservations/:id예약 보기416 / 416무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 자기 데이터 A 대표 200 200 54/54 자기 데이터 A 강남점 지점장 200 200 18/18 다른 지점 A 강남점 지점장 404 404 36/36 자기 데이터 A 강남점 직원 200 200 18/18 다른 지점 A 강남점 직원 404 404 36/36 다른 계정 B 대표 404 404 54/54 다른 계정 B 합정점 지점장 404 404 54/54 다른 계정 B 합정점 직원 404 404 54/54 다른 방문자 다른 방문자의 A 대표 404 404 54/54 다른 계정 A 대표 404 404 36/36 POST/reservations예약 등록·변경9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 남의 아이디 참조 B 대표 (A 지점 참조) 404 404 1/1 남의 아이디 참조 B 대표 (A 서비스 참조) 404 404 1/1 남의 아이디 참조 B 대표 (A 직원 참조) 404 404 1/1 다른 방문자 다른 방문자의 A 대표 404 404 1/1 다른 지점 A 강남점 지점장 404 404 1/1 자기 데이터 B 대표 201 201 1/1 다른 계정 A 대표 404 404 1/1 PATCH/reservations/:id예약 등록·변경160 / 160무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 다른 계정 B 대표 404 404 66/66 다른 방문자 다른 방문자의 A 대표 404 404 54/54 다른 지점 A 강남점 지점장 404 404 36/36 다른 계정 A 데이터 다시 읽기 바뀐 것 없음 바뀐 것 없음 1/1 자기 데이터 A 대표 200 200 1/1 GET/sales매출 보기9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 권한 없는 역할 A 강남점 직원 403 403 1/1 권한 없는 역할 B 합정점 직원 403 403 1/1 자기 데이터 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 계정 B 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 방문자 다른 방문자의 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 GET/sales/summary매출 보기9 / 9무엇을 누가 기대 결과 건수 로그인 없이 로그인 안 함 401 401 1/1 로그인 없이 위조한 세션 401 401 1/1 권한 없는 역할 A 강남점 직원 403 403 1/1 권한 없는 역할 B 합정점 직원 403 403 1/1 자기 데이터 A 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 A 강남점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 계정 B 대표 200, 자기 범위만 200, 자기 범위만 1/1 다른 지점 B 합정점 지점장 200, 자기 범위만 200, 자기 범위만 1/1 다른 방문자 다른 방문자의 A 대표 200, 자기 범위만 200, 자기 범위만 1/1
표본 서비스
지점이 여러 곳인 사업자의 예약·매출 관리 서비스입니다. 필라테스 스튜디오(3개 지점)와 헤어살롱(2개 지점) 두 사업자의 표본 계정 12개가 있고, 대표·지점장·직원 역할마다 볼 수 있는 범위가 다릅니다. 방문자마다 따로 체험 데이터를 만들고, 매일 04:00 에 처음 상태로 되돌립니다.
표본 서비스 열기만든 것
- 서버
- Cloudflare Workers · Hono · D1(SQLite) · Drizzle
- 계약
- zod 스키마 하나를 서버 검증과 화면 응답 확인에 함께 사용
- 시험
- Vitest + Workers 런타임 · 로컬 D1 · 경로 전수 대조 · 함정 경로
- 화면
- React 19 · React Router 7 · TanStack Query · Tailwind CSS
- 운영
- IP 기준 요청 제한(쓰기는 따로 좁게) · 방문자별 체험 데이터 · 매일 정해진 시각 초기화(cron)
베베다가 직접 만든 시제품입니다. 화면의 사업자·사람·주소·연락처는 지어낸 값입니다.