docs(rules): 13단계 부품 — 제한된 세션 · 운영 도구 · 백업 · 감시 · 스킬 표 · env
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m37s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 12m57s
CI / Frontend (lint · type-check · test · build) (pull_request) Failing after 7m54s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m5s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 12m3s

- 스킬 표: nestjs(제한된 세션 · 2단계 필수 정책 · 받은편지함 알림 · 운영 도구 · 15초 동기화 · 만료 계정의 엔진 사실), vue3(제한된 세션 화면 · 로그인 틀 단계 · 유예 줄 · 메일 프로그램 연결 · 30일 줄),
  docker(이미지 표 — PostgreSQL · restic · shellcheck, 자동 설정 server, 호스트 systemd 백업 · 감시 절, setup 의 관리자 단단히 · 휴지통 30일, 로그 앵커 규칙, CI infra 잡).
- 규칙: security(제한된 세션 · 운영 도구 비밀 · 관리자 계정 · 알림 메일 링크 없음 · 퇴사 마무리 · 백업 비밀 · restic 의 root 예외), logging(컨테이너 로그 보존 · 실행 기록 · 자동 설정 쿼리),
  env-structure(자동 설정 env · DB_IMAGE · ops.env 예외 · OPS_ALLOW_NO_TTY), error-codes(AUTH_006 ~ AUTH_010), checklist(13단계 운영 준비), release-ops 담당 범위(mail/ · ops/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:37:30 +09:00
parent ad6cea79fc
commit 6c8c54d117
9 changed files with 75 additions and 3 deletions
+14
View File
@@ -29,6 +29,16 @@
- **끊은 세션은 바로 막는다**(12단계) — 액세스 토큰의 `sid` 와 서버가 1시간 기억한 끊은 세션(뜰 때 DB 에서 채운다)으로 가드가 거절한다. 액세스 토큰 만료까지 기다리지 않는다
- **엔진이 부르는 길**(웹훅 — 로그인 기록)은 세 겹으로 받는다: 앞단 nginx 가 `/api/internal/` 을 닫고, 서버가 접속 주소(소켓 — `X-Forwarded-For` 가 아니다)가 앱 네트워크인지 본문을 받기 전에 보고,
본문 원문의 HMAC-SHA256(`MAIL_WEBHOOK_SECRET`)을 상수 시간으로 견준다. 키가 비면 모두 거절한다
- **제한된 세션**(13단계 — 새 비밀번호 정하기 · 2단계 인증 켜기 전): 로그인은 되지만 허용한 경로(`@AllowRestrictedSession` — `/api/auth/*` · 2단계 켜기)만 지나고 그 밖은 403 `AUTH_010` 이다.
제한을 푸는 요청은 **그 요청의 세션만** 풀고, 다음 제한이 있으면 그 제한으로 바꾼다. 2단계를 켜면 그 직원의 다른 세션 가운데 2단계를 거친 적 없는(`twoFactorSetup`) 세션은 끊는다 —
비밀번호만 아는 다른 사람의 세션이 함께 열리지 않게(2026-11-02 리뷰). 엔진은 2단계를 강제하지 못하고, 비밀번호가 만료되면 웹메일(대리 접근)은 메일 오류만 보이므로 서버가 알아채 그 단계로 보낸다
- **운영 도구**(서버 안 CLI — 13단계)의 관리자 비밀번호는 실행할 때 터미널에서만 받는다 — 화면에 보이지 않게 · 저장하지 않음 · 실행 기록 · 로그 · 예외 문장 · 프로세스 인자에 없음 · 입력 기록(↑) 0(`historySize: 0` — 2026-11-02 리뷰).
표준 입력이 TTY 가 아니면 묻기 전에 멈춘다(자동 시험만 `OPS_ALLOW_NO_TTY=1`). 임시 비밀번호는 crypto 무작위 16자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다
- **메일 엔진 관리자 계정**(13단계 setup): 대리 접근 권한(`impersonate`)이 없고 허용 IP 는 앱 네트워크다 — 관리자 비밀번호가 새도 바깥 993 · JMAP 에서 직원 메일함에 들어가지 못한다.
되돌리는 길은 복구 관리자뿐이라 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 한다(없으면 걸지 않고 멈춘다). 게이트웨이 비밀번호는 여전히 관리자급이다(위 줄)
- **받은편지함 알림 메일**(서버가 넣는 시스템 메일)에는 링크를 넣지 않는다 — 할 일은 메뉴 경로 글로. "회사 보안 알림에는 링크가 없다"는 습관이 링크 달린 피싱을 드러낸다
- **퇴사 마무리**(주소를 다시 쓸 수 있게): 엔진 계정을 지운 뒤 우리 DB 에서 그 주소로 키링한 행을 모두 지운다 — 남기면 같은 주소의 새 사람에게 옛 로그인 기록 · 2단계 설정 · 서명이 보인다.
끊은 세션 행은 끊은 기록을 서버가 동기화로 읽어야 하므로 바로 지우지 않는다(정리 cron)
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
@@ -110,10 +120,14 @@
- 라우트 단위 조정: `@Throttle({ default: { ttl, limit } })` / 제외: `@SkipThrottle()`
- 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 **더 엄격한 한도**를 따로 건다
- ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
- **백업 비밀**(13단계): restic 비밀번호 · Object Storage 키는 서버의 `/etc/arasystem/`(root 0600)에 두고 **서버 밖(비밀번호 관리자)에도** 둔다 — 잃으면 백업을 풀 수 없다.
백업에는 env 파일이 들어간다(암호화). 운영 스크립트는 비밀을 `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트로만 넘기고 명령줄(`ps` 로 보인다)에 싣지 않는다 — Healthchecks 주소도(`curl -K -`)
- **컨테이너 root 실행 금지 — 개발 환경 포함.** 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
- 프론트엔드 운영: `nginxinc/nginx-unprivileged` + `listen 8080` (비-root 는 1024 미만 포트 바인드 불가)
- Node 스테이지: `RUN chown node:node /app` → `USER node` → `COPY --chown=node:node`
(소유권을 먼저 넘겨야 익명 볼륨 `node_modules` 가 node 소유로 생성되어 캐시 쓰기가 가능하다)
- 예외(13단계 — 호스트 systemd 의 백업 · 복원 보조 컨테이너): restic 은 다른 소유자(메일 엔진 UID 2000 등)의 볼륨을 읽으려고 root 로 돌되 `--cap-drop ALL` + `DAC_READ_SEARCH` ·
`no-new-privileges` 만(복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER` 를 더함). 앱 컨테이너가 아니라 운영 스크립트가 잠깐 띄우는 것이다 ([[docker]] "백업 · 감시")
- DB·Redis 는 `expose` 만 사용해 내부 네트워크에만 노출한다.
개발 편의로 호스트에 열어야 한다면 **반드시 `127.0.0.1` 로 한정**한다 — `"${DB_PORT}:5432"` 는 `0.0.0.0` 바인딩이라
같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.