move_detect/server/migrations/2026-08-09-safety-platform.sql
유창욱 7daee0b372 fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.

CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
  edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
  끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
  검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
  허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
  (tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
  프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
  지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
  한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
  식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
  (/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
  전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
  경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.

HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
  그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
  공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
  04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
  Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
  DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
  이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.

MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
  크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
  claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
  정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
  발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
  공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.

LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
  읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
  PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.

수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.

부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.

검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
2026-08-18 10:33:02 +09:00

218 lines
13 KiB
SQL

-- 안전 플랫폼 확장 — 통보·억제·증거무결성·운영지표.
--
-- 이 마이그레이션 하나로 다음 기능의 저장소를 연다. 기능별로 파일을 쪼개지 않은 이유는
-- 서로를 참조하기 때문이다(억제 규칙은 통보를 막고, 통보 실패는 감사로그에 남고, 감사로그는
-- 해시체인으로 봉인된다). 부분 적용된 중간 상태가 더 위험하다.
--
-- 1. move_audit_log 해시체인 — 증거 무결성 (schema.sql 이 "P3 로 연기"라 적어둔 항목)
-- 2. move_suppressions — 정비/작업허가 중 알람 억제 (사유·기간·감사 필수)
-- 3. move_alert_channels/_log — 통보 경로와 전달 이력 (지금까지 통보 수단이 전무했다)
-- 4. move_shift_handovers — 교대 인수인계 기록
-- 5. move_risk_controls — 감지 유형 ↔ 위험요인·대책 (위험성평가 연계)
-- 6. move_device_metrics — 엣지 성능 시계열 (지금은 순간값만 있어 열화를 못 본다)
-- 7. move_thresholds 감사 컬럼 — 편집 UI 가 생기므로 누가 언제 바꿨는지 남긴다
-- 8. move_actions.event_id — 어떤 사건이 그 조치의 계기였는지
-- 9. move_events CHECK 확장 — 신규 감지 유형
--
-- 적용 조건: 2026-08-08-actions-site.sql 이후.
-- ---------------------------------------------------------------------------
-- 1. 감사로그 해시체인
-- 각 행은 직전 행의 entry_hash 를 prev_hash 로 물고, 자신의 내용+prev_hash 를 sha256 한
-- 값을 entry_hash 로 갖는다. 중간 행을 지우거나 고치면 그 이후 전 구간의 사슬이 끊긴다.
-- (삭제·수정을 "막는" 것이 아니라 "숨길 수 없게" 만드는 것이 목적이다. append-only 권한을
-- DB 레벨에서 강제할 수 없는 공유호스팅에서는 이것이 실질적인 방어선이다.)
-- 기존 행은 NULL 로 남는다 — 체인 이전 구간임을 검증 도구가 그대로 보고한다.
ALTER TABLE move_audit_log
ADD COLUMN prev_hash CHAR(64) NULL AFTER ts,
ADD COLUMN entry_hash CHAR(64) NULL AFTER prev_hash,
ADD KEY ix_audit_log_entry_hash (entry_hash);
-- ---------------------------------------------------------------------------
-- 2. 알람 억제 (정비 모드 / 작업허가)
-- 오탐의 최대 원인은 "계획된 작업"이다. 정비 중 제한구역 진입이 전부 알람이면 현장은
-- 시스템을 끄고, 그 다음 진짜 사고를 놓친다. 억제는 감지를 멈추지 않는다 — 이벤트는 그대로
-- 적재되고 통보만 막힌다. 사후 조사에서 "그때 무슨 일이 있었나"가 사라지면 안 되기 때문이다.
CREATE TABLE move_suppressions (
suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
scope_type VARCHAR(16) NOT NULL, -- device | camera | zone
scope_id VARCHAR(64) NOT NULL, -- 해당 스코프의 식별자
site VARCHAR(64) NULL, -- 접근제어 스코프
reason VARCHAR(500) NOT NULL, -- 필수: 사유 없는 억제는 허용하지 않는다
starts_at BIGINT UNSIGNED NOT NULL,
ends_at BIGINT UNSIGNED NOT NULL, -- 무기한 억제 없음. 반드시 만료된다.
created_by VARCHAR(190) NOT NULL,
created_at BIGINT UNSIGNED NOT NULL,
revoked_by VARCHAR(190) NULL, -- 조기 해제
revoked_at BIGINT UNSIGNED NULL,
PRIMARY KEY (suppression_id),
KEY ix_suppressions_window (ends_at, starts_at),
KEY ix_suppressions_scope (scope_type, scope_id),
KEY ix_suppressions_site (site),
CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 3. 통보 경로와 전달 이력
-- 폐쇄망이라 FCM/Slack 을 쓸 수 없다. 사내에서 실제로 닿는 경로만 둔다:
-- local — 엣지 노드의 경광등/부저 (GPIO). 인터넷이 필요 없는 유일한 경로이자 1순위.
-- email — 사내 SMTP 릴레이
-- webhook— 사내 시스템(그룹웨어·문자게이트웨이) HTTP 엔드포인트
-- config_json 은 경로별 설정(수신자, URL, GPIO 핀 등). 비밀번호류는 넣지 않는다 —
-- SMTP 인증정보는 config.php 에 두고 여기서는 참조만 한다.
CREATE TABLE move_alert_channels (
channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
kind VARCHAR(16) NOT NULL, -- local | email | webhook
name VARCHAR(190) NOT NULL,
config_json JSON NOT NULL,
min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
enabled TINYINT(1) NOT NULL DEFAULT 1,
-- 에스컬레이션 단계. 0 = 즉시. 1 이상이면 escalate_after_s 만큼 미확인일 때만 발송한다.
step INT UNSIGNED NOT NULL DEFAULT 0,
escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (channel_id),
KEY ix_alert_channels_site (site, enabled),
CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')),
CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 전달 이력. (event_id, channel_id) UNIQUE 가 중복 발송을 막는 유일한 방어선이다 —
-- 디스패처는 크론으로 돌고 겹쳐 실행될 수 있다.
CREATE TABLE move_alert_log (
alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL, -- pending | sent | failed | suppressed
attempts INT UNSIGNED NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
queued_at BIGINT UNSIGNED NOT NULL,
sent_at BIGINT UNSIGNED NULL,
PRIMARY KEY (alert_id),
UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id),
KEY ix_alert_log_status (status, queued_at),
CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')),
CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 4. 교대 인수인계
-- 24시간 브리프는 있으나 교대 경계와 책임 이관이 없었다. 인수자가 "무엇을 넘겨받았는지"를
-- 명시적으로 확인한 기록이 남아야 미처리 이벤트가 교대 사이에서 증발하지 않는다.
CREATE TABLE move_shift_handovers (
handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
from_user VARCHAR(190) NULL, -- 최초 교대는 인계자가 없다
to_user VARCHAR(190) NOT NULL,
period_start BIGINT UNSIGNED NOT NULL, -- 인계 대상 구간
period_end BIGINT UNSIGNED NOT NULL,
unacked_count INT UNSIGNED NOT NULL, -- 인계 시점 스냅샷 (사후 재계산과 대조 가능)
severe_count INT UNSIGNED NOT NULL,
note VARCHAR(1000) NULL,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (handover_id),
KEY ix_handovers_site_created (site, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 5. 위험성평가 연계
-- 감지 유형 하나하나가 위험요인 하나에 대응한다. 이 표가 있어야 "이 시스템이 무엇을
-- 통제하고 있는가"를 서식으로 뽑을 수 있고, 개선조치가 어느 위험요인을 낮췄는지 이어진다.
CREATE TABLE move_risk_controls (
control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
site VARCHAR(64) NULL,
event_type VARCHAR(32) NOT NULL, -- move_events.event_type 와 같은 코드
hazard VARCHAR(300) NOT NULL, -- 위험요인
control VARCHAR(500) NOT NULL, -- 현재 통제 대책
severity_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (강도)
likelihood_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (빈도)
reviewed_by VARCHAR(190) NULL,
reviewed_at BIGINT UNSIGNED NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (control_id),
UNIQUE KEY uq_risk_controls_site_type (site, event_type),
CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5),
CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 6. 엣지 성능 시계열
-- move_devices 는 마지막 값만 덮어쓴다. "어제부터 추론 지연이 두 배가 됐다"를 볼 수 없어
-- 서서히 죽는 노드를 놓친다. 하트비트마다 한 행씩 append 하고 보존기간으로 잘라낸다.
CREATE TABLE move_device_metrics (
metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL,
ts BIGINT UNSIGNED NOT NULL,
fps DOUBLE NULL,
temp DOUBLE NULL,
throttle TINYINT(1) NULL,
queue_pending INT UNSIGNED NULL,
cameras_total INT UNSIGNED NULL,
cameras_erroring INT UNSIGNED NULL,
errors INT UNSIGNED NULL,
PRIMARY KEY (metric_id),
KEY ix_device_metrics_device_ts (device_id, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------------------
-- 7. 임계값 감사 컬럼 (편집 UI 도입)
-- 지금까지 이 표는 phpMyAdmin 으로 직접 고치는 것이 유일한 방법이었다(스키마 주석 참조).
-- 화면이 생기면 누가 언제 바꿨는지가 사고 조사에서 반드시 필요해진다.
ALTER TABLE move_thresholds
ADD COLUMN updated_by VARCHAR(190) NULL,
ADD COLUMN updated_at BIGINT UNSIGNED NULL;
-- ---------------------------------------------------------------------------
-- 8. 조치 ↔ 계기 사건
ALTER TABLE move_actions
ADD COLUMN event_id INT UNSIGNED NULL AFTER site,
ADD KEY ix_actions_event (event_id);
-- ---------------------------------------------------------------------------
-- 9. 신규 감지 유형 ★ 이 블록만 문법이 DB 엔진마다 다릅니다 — 아래 안내를 읽으세요 ★
--
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
--
-- 이 CHECK 는 **2차 방어선**이지 검증 계층이 아닙니다. 1차 검증은 애플리케이션의
-- App\Ingest\EventValidator 이고(그쪽은 이미 신규 유형을 받습니다), tests/test_event_type_parity.py
-- 가 둘의 일치를 강제합니다. 따라서 이 블록이 실패하거나 건너뛰어져도 시스템은 정상 동작합니다.
-- 다만 그 경우 **신규 유형의 INSERT 가 제약 위반으로 거부**되므로, 아래 셋 중 맞는 것을 반드시
-- 하나 실행하십시오.
--
-- (A) MySQL 8.0.16 이상 — `DROP CHECK` 지원:
-- ALTER TABLE move_events DROP CHECK chk_events_type;
--
-- (B) MariaDB (10.2.22+) — `DROP CHECK` 없음, `DROP CONSTRAINT` 사용:
-- ALTER TABLE move_events DROP CONSTRAINT chk_events_type;
--
-- (C) 제약이 애초에 없는 환경 (MySQL 5.7 은 CHECK 를 파싱만 하고 무시했습니다):
-- DROP 을 건너뛰고 아래 ADD 만 실행하십시오. ADD 가 "이미 존재" 오류를 내면
-- 그 환경에는 제약이 살아 있는 것이므로 (A) 또는 (B) 로 돌아가십시오.
--
-- 어느 것을 실행해야 할지 모르겠다면 먼저 확인하십시오:
-- SELECT VERSION();
--
-- 아래 DROP 은 (A) 기준으로 적어 두었습니다. MariaDB 라면 이 한 줄을 (B) 로 바꾸세요.
ALTER TABLE move_events
DROP CHECK chk_events_type;
ALTER TABLE move_events
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
'camera_moved','camera_obstructed','low_light','unacked_escalation',
'ppe_missing','proximity','fire_smoke'
));
-- 검증:
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
-- php tools/verify_audit_chain.php -- 사슬 무결성
-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';
-- -- 신규 유형이 실제로 들어가는지 (넣고 바로 지웁니다):
-- INSERT INTO move_events (device_id,camera_id,event_type,severity,occurred_at,received_at)
-- VALUES ('_migration_probe','_probe','camera_moved','high',0,0);
-- DELETE FROM move_events WHERE device_id = '_migration_probe';