보안 · 프라이버시 · APPI 준수 (SR-*)
이 문서는 techspec 07-security/security-privacy.md의 보안·프라이버시 설계를 테스트 가능한 보안 요구사항(SR-*)으로 형식화한다. 멀티테넌트 격리(애플리케이션 레벨 RLS), scope→endpoint→role 인가 매트릭스, 個人情報保護法(APPI) 5대 의무(동의·목적 제한·보존·삭제·안전관리), 마스킹 함수, 암호화·키 관리, 감사 로그를 고정한다. 모든 보존/삭제 기간은 법무 서명 전 추정치이며, 법령 사실에는 확인, 미확정 규제 포맷에는 RR-LEGAL-001 법무 서명 플래그를 단다. 모든 수치는 마스터 §4 정규표에서 인용한다.
SR-* 보안 요구사항만 소유한다. 토큰 TTL 정책(FR-AUTH-001: access 3600s · refresh 2592000s · ingest 604800s)은 functional 페이지가 소유하므로 여기서는 링크만 한다. 보존 기간 정규치(RET-DO/EVENT/GPS/REG-001)는 마스터 §4.5에서 인용하고 본 페이지의 SR-APPI-RETENTION-001에서 보존 의무로 형식화한다.
1. 위협 모델 (SR-THREAT-001)
자산 → 위협 → 1차 완화 → 이를 형식화하는 SR. 진입 단계 설계 의도이며 일부 한도·정책은 운영 베이스라인으로 조정한다. 시장·법령 사실(個人情報保護法상 위치=개인정보 가능성, project44형 캐리어 공유 제어)에는 확인을 붙인다.
요구. 시스템은 아래 위협 모델 표의 각 자산·위협에 대해 표기된 1차 완화를 반드시 구현하고, 각 행을 그에 대응하는 SR로 추적해야 한다(MUST). 위협 모델은 분기 1회 또는 신규 외부 인터페이스 도입 시 반드시 재검토해야 한다(MUST).
| 자산 | 위협(STRIDE 매핑) | 1차 완화 | 관련 SR |
|---|---|---|---|
| 테넌트 화물·캐리어 데이터(D1) | Information Disclosure — 화주 A가 화주 B 화물 열람 | 모든 D1 조회 tenant_id 강제(앱-레벨 RLS), 타 테넌트는 404 | SR-TENANT-001 · SR-AUTHZ-001 |
| 드라이버 위치·실명·番号板 | Information Disclosure — 다계층(화주↔元請↔下請↔孫請) 과다 공개 | 案件 배정 기간 한정 공개, 스코프별 PII 마스킹, 受取人은 ETA만 | SR-PII-001 · SR-APPI-PURPOSE-001 |
| API Key / JWT | Spoofing / Elevation — 키 탈취 후 대량 추출 | 최소 스코프, kid+revocation-list, 레이트리밋(NFR-SEC-RL-001) | SR-CRYPTO-001 · SR-AUTHZ-001 |
| 화주 웹훅 엔드포인트 | Tampering / Replay — 가짜 이벤트·재전송 | HMAC-SHA256({timestamp}.{raw_body}) + replay ±300s | FR-DLV-WH-001(참조) |
화물 ID 공간(SHP-…) | Information Disclosure — 순차 ID 열거 | 불투명 ID + 테넌트 스코프 + 404 통일 + 레이트리밋 | SR-TENANT-001 |
| 전송·저장 채널 | Information Disclosure — 중간자·스토리지 접근 | TLS 종단(엣지), 시크릿은 wrangler secret/KV, PII 마스킹·보관 분리 | SR-CRYPTO-001 |
| console 브라우저 토큰 | Tampering — XSS로 localStorage 토큰 탈취 | httpOnly 쿠키 하드닝 + sunset 일정 | SR-TOKEN-STORE-001 |
| 드라이버 동의 상태 | Repudiation / Compliance — 동의 없는 수집 | driver.consent_at 없으면 ingest 거부(하드게이트) | SR-APPI-CONSENT-001 |
수용기준
- Given 위협 모델 표의 임의 행, When 해당 완화가 코드/테스트에 부재, Then 릴리스 게이트는 그 행의 관련 SR을 미충족으로 표시한다(추적성 매트릭스 연동 — acceptance-traceability).
- Given 신규 외부 인터페이스(예: EDI·텔레매틱스 수신), When 위협 모델 재검토 없이 머지 시도, Then PR 체크가 차단한다(pipeline-ops).
2. 인가 매트릭스 (SR-AUTHZ-001)
인가는 두 축이다 — 사람 role(JWT role 클레임)과 머신 scope(API Key/토큰 scopes). scope는 endpoint와 1:1로 매핑되고, role은 기본 scope 집합을 부여한다. 아래 매트릭스는 server/src/lib/auth.ts의 ROLE_SCOPES 및 server/src/types/index.ts의 Scope·Role 유니온을 as-built 그대로 형식화한 것이다.
요구. 모든 보호 엔드포인트는 자신이 요구하는 scope를 requireScope(ctx, …)로, role 제약은 requireRole(ctx, …)로 반드시 강제해야 한다(MUST). 스코프 누락은 403 PERMISSION_DENIED로 응답해야 한다(MUST). API Key는 반드시 최소 scope만 부여해야 한다(MUST, least-privilege).
role × scope 매트릭스 (✓=기본 부여, —=미부여; auth.ts ROLE_SCOPES as-built)
| role \ scope | shipments: read | shipments: write | positions: read | positions: write | vehicles: read | geofences: read | webhooks: * | analytics: read | reports: read |
|---|---|---|---|---|---|---|---|---|---|
SHIPPER_ADMIN | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ r/w | ✓ | ✓ |
SHIPPER_VIEWER | ✓ | — | ✓ | — | — | ✓ | — | — | ✓ |
CARRIER_OPS | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — | — |
DRIVER | ✓ | — | — | (*) | — | — | — | — | — |
SUPPORT(내부) | ✓ | — | ✓ | — | — | — | — | ✓ | ✓ |
CONSIGNEE(受取人, Phase 1 미존재) | tracking-only | — | — | — | — | — | — | — | — |
(*) DRIVER는 read 라우트의 positions:read(경로 replay)를 받지 않는다. 백그라운드 GPS 업로드용 positions:write는 일반 role-scope가 아니라, issueIngestToken()이 강제로 ['positions:write']만 실은 ingest 전용 토큰(FR-AUTH-001 · ingest TTL 604800s)을 통해서만 부여된다. CONSIGNEE role은 아직 코드에 없으며(Role 유니온은 5종), 受取人 가시성은 role이 아닌 tracking 응답의 position 생략으로 구현한다(SR-PII-001).
수용기준
- Given
SHIPPER_VIEWERJWT, WhenPOST /v1/shipments(shipments:write필요) 호출, Then403 PERMISSION_DENIED(missing scope: shipments:write). - Given ingest 토큰(
scopes=['positions:write']), WhenGET /v1/shipments/:id/positions(positions:read필요) 호출, Then403. WhenPOST /v1/ingest/positions호출, Then 정상 처리. - Given
role='constructor'등 프로토타입체인 오염 시도, When 토큰 검증, ThenVALID_ROLESSet 멤버십으로 거부(UNAUTHENTICATED) —in연산자 구멍 차단.
3. 테넌트 격리 (SR-TENANT-001)
요구. 모든 D1 읽기/쓰기는 인증 컨텍스트의 tenant_id를 WHERE 절에 반드시 바인딩해야 하며, 이 tenant_id는 토큰/키 클레임에서만 주입되고 클라이언트 입력으로는 절대 바인딩되지 않아야 한다(MUST). 모든 데이터 접근은 단일 리포지토리 레이어(server/src/lib/db.ts)를 통과시켜, tenant_id 바인딩 없는 쿼리가 코드상 작성 불가능하도록 한다(앱-레벨 RLS). 타 테넌트 리소스 요청은 403이 아니라 404로 응답하여 ID 존재 여부조차 노출하지 않아야 한다(MUST). DRIVER role 컨텍스트는 자신의 driver_id(=ctx.sub)로 한 번 더 스코프하여 배정된 案件만 보이게 해야 한다(MUST).
수용기준
- Given 테넌트 A 토큰, When 테넌트 B 소유
shipment_id로GET /v1/shipments/:id, Then 0행 →404 SHIPMENT_NOT_FOUND(존재 비노출). 응답·로그에 테넌트 B 식별자 미노출. - Given ingest 배치에 타 테넌트/미배정
shipment_id포함, WhenPOST /v1/ingest/positions, Then 접근 가능한 핑만 수용(repo.getShipment스코프 필터), 전부 불가 시403 PERMISSION_DENIED. - Given 리포지토리 레이어 우회 쿼리 PR, When CI, Then 회귀셋이 차단(pipeline-ops).
4. PII 마스킹 함수 (SR-PII-001)
스코프별로 개인정보 노출을 최소화한다(data minimization). 마스킹은 뷰 시점에서 결정론적 순수 함수로 적용한다 — 원시값은 운송사(고용 관계) 뷰에서만 노출. 대상 필드는 server/src/types/index.ts의 driver.display_name(// masked in shipper scope (APPI))·vehicle.plate(// 番号板 — masked in shipper scope (APPI))이다.
요구. 화주(SHIPPER_*) 및 受取人(CONSIGNEE) 스코프로 직렬화되는 모든 응답은 아래 마스킹 규칙을 반드시 적용해야 한다(MUST). 운송사(CARRIER_OPS, 자사) 스코프는 자사 드라이버 실명·번호판을 원시 노출할 수 있다(MAY, 고용 관계); 타 캐리어 데이터는 노출 불가(MUST NOT).
마스킹 규칙 (함수형 정의)
| 필드 | 함수 | 화주 뷰 출력 예 | 운송사(자사) 뷰 | 受取人 뷰 |
|---|---|---|---|---|
driver.display_name | maskName(s) = head(s,1) + "**" (姓 1자 유지, 잔여 별표화) | 佐** | 원시 실명 | 미노출 |
vehicle.plate | maskPlate(p) = region + " " + cls[0] + "*-**" (지역명+분류 1자리 유지) | 品川 1*-** | 원시 番号板 | 미노출 |
position(실시간 좌표) | maskPosition(scope) = (scope==CONSIGNEE) ? omit : pass | 노출(ETA+위치) | 노출 | 생략(omit) — ETA만 |
TrackingSnapshotDto)의 position?는 옵셔널이며, 주석 // `position` is omitted for consignee scope (APPI masking) 그대로 受取人 스코프에서 키 자체를 제거한다(null 전송이 아님). display_name·plate 마스킹은 현재 server/src/routes/shipments.ts에 // TODO (Phase 1 late): … scope-based masking of PII (APPI)로 미구현(STUB) — 본 SR이 그 TODO를 정규 수용기준으로 승격한다.
수용기준
- Given 화주 토큰, When 드라이버
display_name="佐藤太郎"·plate="品川 12-34"화물 조회, Then 응답은佐**·品川 1*-**만 포함하고 원시값을 절대 포함하지 않는다(스냅샷 테스트). - Given 受取人 스코프 tracking 조회, When 응답 직렬화, Then
position키 부재 ANDnext_stop.eta존재. - Given 마스킹 함수, When 동일 입력 반복, Then 동일 출력(결정론적, 가역 정보 누출 없음).
5. 個人情報保護法(APPI) 5대 의무
드라이버 위치는 차량 위치·番号板·실명과 결합 가능하므로 個人情報保護法상 개인정보로 다룬다 확인(Research/delivery-layer). 아래 5개 요구사항 카드가 수집 목적 명시·동의, 이용 제한, 보존, 삭제권, 안전관리(암호화는 §6)를 형식화한다. 모든 보존/삭제 기간은 법무 서명 전 추정이며, 미확정 항목은 RR-LEGAL-001 법무 서명 플래그를 단다.
요구. 시스템은 드라이버의 동의 시각(driver.consent_at)이 없으면 그 드라이버의 위치 핑 수집을 반드시 거부해야 한다(MUST). 인제스트 경로(POST /v1/ingest/positions)는 각 드라이버에 대해 consentGate(consent_at)를 반드시 평가하고, false면 해당 핑을 수용하지 않아야 한다(MUST). 동의 철회 경로(앱 내)를 제공하고, 철회 시 이후 수집을 중단해야 한다(MUST).
consentGate(consentAt) = consentAt != null은 server/src/lib/auth.ts에 존재하나, server/src/ingest/positions.ts 핫패스에서 호출되지 않는다(as-built: "consentGate()는 lib에 존재하나 ingest 경로에서 미호출 — APPI 갭"). 본 SR은 그 배선을 P0 차단 요구로 고정한다.
수용기준
- Given
consent_at = null인 드라이버 토큰, WhenPOST /v1/ingest/positions로 핑 업로드, Then 핑은 거부되고 DO·이벤트·R2에 기록되지 않는다(PERMISSION_DENIED또는 핑 단위 drop + 거부 카운트). - Given 동의 후 철회한 드라이버, When 철회 시각 이후 핑, Then 동일하게 거부.
- Given
consent_at설정된 드라이버 + 활성 案件, When 핑, Then 정상 수용.
요구. 수집한 위치는 명시한 목적(가시성·荷待ち 측정·ETA·규제 산출물) 내에서만 이용해야 한다(MUST). 목적 외(무관한 마케팅·개별 드라이버 감시)로 전용하지 않아야 한다(MUST NOT). 위치 공유는 案件이 실린 기간 동안만 ON("항상 추적"이 아니라 "업무 중 추적")이며, 화주↔캐리어 공유 범위는 계약서와 시스템 권한이 일치해야 한다(MUST). 案件 종료 시 공유는 자동 OFF되어야 한다(MUST).
수용기준
- Given 案件이 종료된 shipment, When 화주가 해당 캐리어 차량 실시간 위치 조회, Then 위치 미노출(공유 OFF).
- Given 신규 데이터 사용처 추가 PR, When 리뷰, Then 목적 적합성 점검(Inspection) 체크리스트 통과 필수.
요구. 데이터는 수명별로 저장 위치·보존 기간을 분리하고(핫/이벤트/원시/규제), 보존 기간 경과분은 자동 만료해야 한다(MUST). 무기한 보존하지 않으며, 목적이 끝난 데이터는 아카이브/삭제 경로를 둔다(MUST). 아래 보존표의 수치는 마스터 §4.5 정규치이며 전부 법무 서명 플래그가 필요하다.
보존표 (마스터 §4.5 인용, 기간은 추정 — 법무 서명 전)
| ID | 데이터 | 저장 위치 | 보존 기간(정규치) | 법무 서명 |
|---|---|---|---|---|
RET-DO-001 | DO 라이브 상태(핫) | Durable Object | 운행 + hibernation 후 1h | 추정 플래그 |
RET-EVENT-001 | D1 의미 이벤트(arrival/exit/dwell) | D1 event·stop | 5년(분쟁/규제) | 추정 플래그 |
RET-GPS-001 | R2 원시 GPS 아카이브(고빈도) | R2 GPS_ARCHIVE | 365일 후 purge(ML 윈도) | 추정 플래그 |
RET-REG-001 | R2 규제 산출물(PDF/CSV) | R2 + D1 메타 | 7년(법정 최소) | 추정 플래그 RR-LEGAL-001 |
수용기준
- Given 원시 GPS 아카이브 객체 age > 365일, When R2 라이프사이클/Cron 배치 실행, Then 해당 객체 purge(테스트: age 경계 ±1일).
- Given 보존표의 임의 기간, When 법무 서명 미완, Then 문서·릴리스 게이트에
추정플래그 표기 유지(서명 시확인으로 승격).
요구. 데이터 주체(드라이버)의 삭제 요청에 대응하는 경로를 두고, 개인 식별 필드를 삭제/익명화하고 R2 원시 GPS 아카이브에서 해당 주체분을 제거하는 절차를 제공해야 한다(MUST). 삭제 요청 접수 후 처리 SLA를 명시해야 한다(초기 확정치 30일 이내 처리, 운영 베이스라인으로 조정 — 설계). 단, 법정 보존 의무가 있는 규제 산출물(RET-REG-001 7년)은 삭제권보다 보존 의무가 우선할 수 있으며(MUST), 충돌 시 "개인 식별과 사실 기록 분리"로 양립시킨다.
driver_id(또는 해시) 접두 파티션을 부여해, 주체 삭제 요청 시 prefix 리스트→batch delete로 O(주체 객체 수)에 제거하는 설계. D1은 식별 필드 익명화(display_name=NULL 등). 절차 상세는 법무·운영 확정 — 추정.수용기준
- Given 드라이버 삭제 요청, When 삭제 잡 실행, Then 해당
driver_id원시 GPS R2 객체 0개 잔존 AND D1 식별 필드 익명화 AND 규제 산출물은 보존(분리 검증). - Given 삭제 요청 접수, When 30일 경과, Then 미처리 건은 알람(SLA 위반).
6. 암호화 · 시크릿 · 키 관리 (SR-CRYPTO-001)
요구. 모든 클라이언트↔엣지·엣지↔외부(웹훅·지도/JMA API) 통신은 TLS(HTTPS)를 반드시 사용해야 한다(MUST). 시크릿(JWT_SIGNING_KEY·WEBHOOK_SIGNING_KEY·MAPS_API_KEY·JMA_API_KEY·LINE_CHANNEL_TOKEN)은 반드시 wrangler secret(환경별)으로 주입하고 코드/레포에 하드코딩하지 않아야 한다(MUST NOT). JWT 서명 키는 키 ID(kid) 기반 다중 키로 운용하여 무중단 교체가 가능해야 하며(MUST), 폐기 키·revocation 목록을 AUTH_KEYS KV로 전파하여 ≤60s 내 무효화(FR-AUTH-001)해야 한다(MUST).
kid 다중 키 + KV revocation을 기술하나, server/src/lib/auth.ts의 실제 구현은 단일 정적 키 JWT_SIGNING_KEY · HS256 · 헤더 kid 없음이다(jwtSign(payload, key, 'HS256') / jwtVerify(token, signingKey(env), 'HS256')). KV 바인딩 AUTH_KEYS는 이미 존재(// API-key meta, JWT key state, revocation list)하나 JWT 검증 경로가 그것을 읽지 않는다. 본 SR은 (1) 토큰 헤더에 kid 부여, (2) AUTH_KEYS KV의 키-상태/revocation-list를 검증 시 강제 조회를 정규 요구로 고정한다. API Key 자체는 api_key.revoked 컬럼으로 즉시 폐기 가능(WHERE … revoked = 0) — DONE.
수용기준
- Given 발급된 JWT, When 토큰 헤더 검사, Then
kid클레임 존재 AND 검증기가kid로AUTH_KEYS에서 해당 키 상태(active/retired/revoked) 조회. - Given
kid가 revocation-list에 등재, When 그 키로 서명된 토큰 제시, Then401 UNAUTHENTICATED≤60s 내(KV 전파). - Given 시크릿, When 레포 스캔, Then 평문 시크릿 0건(CI secret-scan).
- Given API Key
revoked=1, When 해당 키로 호출, Then401(resolveApiKeyrevoked=0필터) — 이미 DONE.
7. 토큰 저장 하드닝 (SR-TOKEN-STORE-001)
요구. 브라우저 컨트롤 타워(console)의 인증 토큰은 XSS로 탈취 가능한 localStorage가 아니라 httpOnly·Secure·SameSite 쿠키로 저장해야 한다(권장→MUST). 전환에는 sunset 일정(localStorage 경로 폐기 기한)을 명시하고, 그 기한까지는 짧은 액세스 TTL과 회전으로 노출 창을 최소화해야 한다(MUST). 결정 추적은 OD-003(토큰 저장 하드닝)이 소유한다.
console/src/lib/auth.ts는 토큰을 localStorage에 둔다(주석: // MVP: 토큰을 localStorage 에 둔다(웹). 후속에 httpOnly 쿠키+짧은 수명/회전으로 강화(07 보안).). XSS 1건이 토큰 전체 탈취로 직결되는 노출면. drift 기간 동안 CSP·입력 sanitize로 XSS 표면을 줄이되, 근본 해소는 httpOnly 쿠키 전환.수용기준
- Given httpOnly 쿠키 전환 완료, When 브라우저 콘솔에서
localStorage조회, Then 인증 토큰 부재 AND 쿠키는HttpOnly; Secure; SameSite속성 보유. - Given sunset 기한, When 기한 경과, Then localStorage 토큰 경로 코드 제거(회귀셋).
8. 감사 로그 (SR-AUDIT-001)
요구. 보안 관련 사건(인증 실패·권한 거부·키 발급/폐기·웹훅 비활성·규제 산출물 다운로드·데이터 주체 삭제)은 반드시 감사 로그로 남겨야 한다(MUST). 각 레코드는 누가(actor=ctx.sub/tenant_id)·무엇(action·resource)·언제(occurred_at, ISO 8601 +09:00 — DM-TS-001)·request_id·outcome(allow/deny)를 포함해야 한다(MUST). 로그에는 개인정보를 최소화하여(위치 원좌표·실명 대신 식별자) request_id로 상관해야 한다(MUST).
감사 레코드 스키마
| 필드 | 의미 | 원천 |
|---|---|---|
request_id | 요청 상관 키(UUID) | requestIdMiddleware(X-Request-Id 헤더/crypto.randomUUID()) |
actor | 누가(ctx.sub · apikey:<key_id>) | auth 컨텍스트 |
tenant_id | 어느 테넌트 | 토큰/키 클레임 |
action · resource | 무엇을(예 report.download · SHP-…) | 라우트 |
occurred_at | 언제(ISO 8601 +09:00) | DM-TS-001 |
outcome | allow / deny / error_code | 핸들러/에러맵 |
수용기준
- Given 권한 거부(
403) 발생, When 요청 처리, Then 감사 레코드 1건 생성(action·actor·request_id·outcome=deny) AND 위치 원좌표·실명 미포함. - Given 규제 산출물 다운로드, When 응답, Then 감사 레코드의
request_id가 API 응답X-Request-Id및 에러 엔벨로프request_id와 동일(상관 가능).
9. 관련 functional 요구 (참조)
아래는 functional 페이지 소유 ID로, 본 페이지 보안 모델이 의존하나 여기서 재정의하지 않는다.
- FR-AUTH-001 — 토큰 TTL: access 3600s · refresh 2592000s(로테이션-only+재사용탐지) · ingest 604800s(positions:write 한정), KV revocation 전파 ≤60s, PBKDF2 ≥100k.
- FR-ACQ-CONSENT-001 — 클라이언트 동의 온보딩(SR-APPI-CONSENT-001의 서버측 게이트와 짝).
- FR-DLV-WH-001 — 웹훅 HMAC-SHA256(
{timestamp}.{raw_body}) · replay ±300s · 재시도 래더 8회. - NFR-SEC-RL-001 — 레이트리밋(현재 코드 미구현, 에러맵
RATE_LIMITED/429존재).
근거·상호참조
- techspec: 07-security/security-privacy.md (위협 모델·인증·인가·APPI·암호화·키 관리·감사) — 본 페이지의 1차 근거
- techspec: 05-delivery-layer/delivery.md (스코프·엔드포인트·404 격리·웹훅 HMAC·受取人 ETA-only)
- techspec: 02-data-model/model.md (
display_name·plate마스킹 필드·carrier.parent_carrier_id) - techspec: 01-architecture/cloudflare-stack.md (KV·Secrets·R2 라이프사이클)
- Research: Research/delivery-layer (멀티테넌시·캐리어 데이터 공유·個人情報保護法) 확인
- code:
server/src/lib/auth.ts(ROLE_SCOPES·requireScope·requireRole·consentGate·issueIngestToken·HS256 단일키) - code:
server/src/lib/db.ts(앱-레벨 RLS SECURITY INVARIANT·tenant 스코프·404 비노출) - code:
server/src/ingest/positions.ts(인제스트 핫패스·tenant 스코프 필터·consentGate 미배선) - code:
server/src/routes/shipments.ts(PII 마스킹 TODO·受取人position생략) - code:
server/src/types/index.ts(Scope·Role유니온·AUTH_KEYSKV·시크릿·TrackingSnapshotDto·request_id) - code:
server/src/middleware/error.ts(requestIdMiddleware·에러 엔벨로프request_id) - code:
console/src/lib/auth.ts(localStorage 토큰 — 하드닝 대상) - TRD 내부: FR-AUTH-001 · DM-ISO-001 · DM-TS-001 · NFR-SEC-RL-001 · RR-LEGAL-001 · OD-003 · acceptance-traceability