자체 시제품

계정별 데이터 격리

여러 사업자가 함께 쓰는 서비스에서 한 계정의 데이터가 다른 계정에 보이지 않게 막는 서버 구조와, 그것을 매번 확인하는 자동 시험. 지점이 여러 곳인 사업자의 예약·매출 관리 서비스를 표본으로 만들었고, Cloudflare Workers·Hono·D1 위에서 돕니다.

시험한 API 경로
15개 (전부)
자동 시험
687건
실패
0건
시험 시간
1.2초

2026년 11월 11일 로컬 시험 결과 · 커밋 c5296c3

막는 것

  • 다른 계정

    404

    B 사업자로 로그인한 채 A 사업자의 예약·지점 아이디를 열면 404. 목록에도 섞이지 않는다.

  • 같은 계정의 다른 지점

    404

    지점장·직원은 소속 지점 데이터만 본다. 다른 지점 예약을 아이디로 열어도 404.

  • 권한 없는 역할

    403

    직원 계정은 매출과 직원 목록을 볼 수 없다. 메뉴만 숨기지 않고 서버가 403 으로 막는다.

  • 남의 아이디를 넣은 쓰기

    404

    예약을 만들 때 다른 계정의 지점·서비스·담당자 아이디를 섞어 보내면 만들지 않고 404.

남의 데이터는 403 이 아니라 404 로 답합니다. 그런 아이디가 있는지조차 알려 주지 않습니다.

구조

  1. 1

    요청

    쿠키에 세션 토큰만 실린다 (httpOnly)

  2. 2

    공용 미들웨어

    허용 출처 · 요청 제한 · 방문자 구분

  3. 3

    권한 검사 한 곳

    세션 → 사용자 → 계정·역할 → 경로가 요구하는 권한

  4. 4

    계정 범위 창구

    모든 조회·수정에 계정(·지점) 조건이 자동으로 붙는다

  5. 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-users1 / 1
    무엇을누가기대결과건수
    로그인 없이로그인 안 함2002001/1
  • POST/session3 / 3
    무엇을누가기대결과건수
    자기 데이터방문자 12012011/1
    다른 방문자방문자 22012011/1
    로그인 없이로그인 안 함4044041/1
  • DELETE/session1 / 1
    무엇을누가기대결과건수
    자기 데이터B 대표204, 이후 401204, 이후 401, 다른 세션 2001/1
  • GET/me9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    자기 데이터A 대표2002001/1
    자기 데이터A 강남점 지점장2002001/1
    자기 데이터A 강남점 직원2002001/1
    자기 데이터B 대표2002001/1
    자기 데이터B 합정점 지점장2002001/1
    자기 데이터B 합정점 직원2002001/1
    자기 데이터다른 방문자의 A 대표2002001/1
  • GET/permissions9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    자기 데이터A 대표2002001/1
    자기 데이터A 강남점 지점장2002001/1
    자기 데이터A 강남점 직원2002001/1
    자기 데이터B 대표2002001/1
    자기 데이터B 합정점 지점장2002001/1
    자기 데이터B 합정점 직원2002001/1
    자기 데이터다른 방문자의 A 대표2002001/1
  • GET/branches9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/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/:id25 / 25
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    자기 데이터A 대표2002003/3
    자기 데이터A 강남점 지점장2002001/1
    다른 지점A 강남점 지점장4044042/2
    자기 데이터A 강남점 직원2002001/1
    다른 지점A 강남점 직원4044042/2
    다른 계정B 대표4044043/3
    다른 계정B 합정점 지점장4044043/3
    다른 계정B 합정점 직원4044043/3
    다른 방문자다른 방문자의 A 대표4044043/3
    다른 계정A 대표4044042/2
  • GET/services9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/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/staff9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    권한 없는 역할A 강남점 직원4034031/1
    권한 없는 역할B 합정점 직원4034031/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/reservations9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/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/:id416 / 416
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    자기 데이터A 대표20020054/54
    자기 데이터A 강남점 지점장20020018/18
    다른 지점A 강남점 지점장40440436/36
    자기 데이터A 강남점 직원20020018/18
    다른 지점A 강남점 직원40440436/36
    다른 계정B 대표40440454/54
    다른 계정B 합정점 지점장40440454/54
    다른 계정B 합정점 직원40440454/54
    다른 방문자다른 방문자의 A 대표40440454/54
    다른 계정A 대표40440436/36
  • POST/reservations9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    남의 아이디 참조B 대표 (A 지점 참조)4044041/1
    남의 아이디 참조B 대표 (A 서비스 참조)4044041/1
    남의 아이디 참조B 대표 (A 직원 참조)4044041/1
    다른 방문자다른 방문자의 A 대표4044041/1
    다른 지점A 강남점 지점장4044041/1
    자기 데이터B 대표2012011/1
    다른 계정A 대표4044041/1
  • PATCH/reservations/:id160 / 160
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    다른 계정B 대표40440466/66
    다른 방문자다른 방문자의 A 대표40440454/54
    다른 지점A 강남점 지점장40440436/36
    다른 계정A 데이터 다시 읽기바뀐 것 없음바뀐 것 없음1/1
    자기 데이터A 대표2002001/1
  • GET/sales9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    권한 없는 역할A 강남점 직원4034031/1
    권한 없는 역할B 합정점 직원4034031/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/summary9 / 9
    무엇을누가기대결과건수
    로그인 없이로그인 안 함4014011/1
    로그인 없이위조한 세션4014011/1
    권한 없는 역할A 강남점 직원4034031/1
    권한 없는 역할B 합정점 직원4034031/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)

베베다가 직접 만든 시제품입니다. 화면의 사업자·사람·주소·연락처는 지어낸 값입니다.