From 3aca674a78df212164eeb8772fb5b04e56142794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 25 Sep 2026 18:34:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=ED=95=B4=EA=B4=80=EA=B3=84?= =?UTF-8?q?=EC=9E=90=20=ED=8E=98=EB=A5=B4=EC=86=8C=EB=82=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=204=EA=B1=B4=20=E2=80=94=20=EC=97=B4=EB=9E=8C=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=C2=B7=20=EC=A6=9D=EA=B1=B0=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=20=C2=B7=20=ED=81=AC=EB=A1=A0=20=EC=83=9D=EC=A1=B4=20?= =?UTF-8?q?=C2=B7=20=EC=A6=9D=EA=B1=B0=20=EC=A0=95=EC=B1=85=20(1.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자· 공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다. 5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다. 읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에 들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전 시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전 얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다. 거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람" 목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다. 크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그 한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과 구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다. 증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇 달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로 자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다. 증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안 주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다: 시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다. 마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번). 검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU --- CHANGELOG.md | 57 ++++++ VERSION | 2 +- docs/INDEX.md | 26 +++ docs/PROJECT_MAP.md | 2 + docs/delivery-audit.md | 51 ++++- docs/영상보관-노조대응.md | 47 +++-- docs/프로젝트-소개서.html | 2 +- docs/프로젝트-소개서.md | 2 +- edge/app.py | 29 ++- server/deploy/README-UPLOAD.md | 1 + ...026-09-25-evidence-hold-and-view-audit.sql | 36 ++++ server/public/assets/app.js | 30 +++ server/public/assets/detail.js | 67 ++++++- server/public/index.php | 11 +- server/schema.sql | 23 +++ .../src/Dashboard/DeviceStatusController.php | 50 ++++- .../src/Dashboard/EventDetailController.php | 44 +++++ server/src/Dashboard/ExportController.php | 12 +- server/src/Dashboard/SnapshotProxy.php | 50 ++++- server/src/Repository/AppStateRepository.php | 18 ++ server/src/Repository/EventRepository.php | 10 + .../Repository/Pdo/PdoAppStateRepository.php | 34 ++++ .../src/Repository/Pdo/PdoEventRepository.php | 15 ++ .../Repository/Pdo/PdoSnapshotRepository.php | 18 +- server/src/Repository/SnapshotRepository.php | 4 +- server/src/View/event_detail.php | 1 + server/src/View/events.php | 1 + server/src/View/layout.php | 5 + server/tests/DispatcherLivenessTest.php | 167 ++++++++++++++++ server/tests/EvidenceHoldTest.php | 184 ++++++++++++++++++ server/tests/PdoIntegrationTest.php | 7 + server/tests/ReadAuditTest.php | 184 ++++++++++++++++++ server/tests/RouteTableTest.php | 1 + server/tests/Support/InMemoryRepositories.php | 10 +- server/tools/dispatch_alerts.php | 20 +- tests/test_app.py | 51 +++++ tests/test_evidence_policy.py | 55 ++++++ 37 files changed, 1284 insertions(+), 43 deletions(-) create mode 100644 server/migrations/2026-09-25-evidence-hold-and-view-audit.sql create mode 100644 server/src/Repository/AppStateRepository.php create mode 100644 server/src/Repository/Pdo/PdoAppStateRepository.php create mode 100644 server/tests/DispatcherLivenessTest.php create mode 100644 server/tests/EvidenceHoldTest.php create mode 100644 server/tests/ReadAuditTest.php create mode 100644 tests/test_evidence_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d06e9..9fbadd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,63 @@ --- +## 1.3.0 — 2026-09-25 · 읽기 감사 · 증거 보존 · 감시자 감시 + +이해관계자 5인(작업자·노조·안전관리자·공장장·설치기사) 관점으로 제품을 다시 검증한 결과 +나온 네 가지입니다. 넷 다 같은 모양이었습니다 — **구현도 테스트도 초록인데, 그 기능이 +존재해야 할 이유가 닿는 지점에서 아무 일도 일어나지 않는 상태.** + +### 추가 — 열람·반출 감사기록 + +이 시스템은 쓰기를 전부 감사했습니다. 임계값 한 번 바꾼 것까지 해시 체인에 들어갑니다. +그런데 **작업자 사진을 열어보는 것은 하나도 남지 않았습니다.** + +감시 시스템과 안전 시스템을 가르는 선이 정확히 거기입니다. 나머지 프라이버시 설계 +(RAM 링버퍼 7.5초·전송 전 얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 +*주장* 에 머물러 있었습니다. 교섭에서 "지난달에 우리 조합원 사진 누가 봤습니까"에 답이 +없으면 "안 봤습니다"는 증명이 아니라 주장입니다. + +- 스냅샷 열람 → 감사 동작 `snapshot_view` (세션당 사진 1회) +- 라벨 데이터셋 대량 반출 → `label_export` (건수·기간과 함께) + +### 추가 — 증거 보존 보류 (`hold_until`) + +스냅샷은 나이만 보고 지워졌습니다. 기본 7일인데 중대재해 조사는 몇 주에서 몇 달이라, +**우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라지는** 상태였습니다. + +admin 만, 건별로, 최대 1년, 감사기록과 함께 보류를 겁니다. 확인 처리만으로 자동 연장되지 +않습니다 — 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다. 만료되면 사람이 해제하지 +않아도 다시 삭제 대상이 됩니다. 사건 상세 화면에 버튼이 생깁니다. + +### 추가 — 통보 디스패처 생존 표시 + +죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 **부재**로 판정합니다. 그런데 그 +크론이 멈추면 아무도 판정하지 않습니다 — 통보·5분 미확인 확대·장치 이상 발행이 전부 그 한 +프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 **그 침묵은 "사고 없음"과 구별되지 +않습니다.** 관리자 화면이 이에 대해 하던 일은 "크론으로 실행 중이어야 합니다"라는 안내문 +한 줄이었습니다. + +이제 크론이 매 실행마다 시각을 남기고, 대시보드가 5분 넘게 갱신이 없으면 배너를 띄웁니다. +감시자의 생존은 감시자가 아닌 쪽이 읽어야 하기 때문입니다. +현장 경광등은 GPIO 직결이라 이 상황에서도 영향받지 않습니다. + +### 변경 — 체류 상한 초과에는 사진을 붙이지 않습니다 + +증거 프레임은 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었습니다. 체류 상한 초과 +(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 **정상 작업자가 규정대로 +일하는 동안 주기적으로 본인 사진이 서버로 가는** 상태였습니다. 위험해서가 아니라 오래 +있어서. + +기준을 명문화했습니다 — **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.** +규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이므로 그대로 둡니다. + +### 마이그레이션 + +`2026-09-25-evidence-hold-and-view-audit.sql` — `move_events.hold_until` 컬럼과 +`move_app_state` 표. README-UPLOAD.md 의 10번입니다. + +--- + ## 1.2.1 — 2026-09-21 · 납품 정비 (기능 변경 없음) 감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다. diff --git a/VERSION b/VERSION index 6085e94..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.1 +1.3.0 diff --git a/docs/INDEX.md b/docs/INDEX.md index 75ba214..3cae797 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -194,3 +194,29 @@ (회전 도구 추가) 버전을 올렸다. 산출물 `move-detect-cafe24-1.2.1-20260921.zip` · `move-detect-pi-1.2.1-20260921-aarch64-cp311-hailo8.tar.gz`(91MB, MoveNet 폴백 포함) +## 2026-09-25 — 1.3.0 (이해관계자 페르소나 검증 4건) + +앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·공장장· +설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다. **5인 중 2인의 +논증이 완전히, 2인이 부분적으로 무너졌다.** 넷 다 "구현도 테스트도 초록인데 그 기능이 지켜야 +할 것이 안 지켜지는" 같은 모양이었다. + +- **열람 감사** — 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 **사진 열람은 하나도 남지 + 않았다.** 감시 시스템과 안전 시스템을 가르는 선이 거기라서, 나머지 프라이버시 설계 + (RAM 링버퍼·전송 전 블러·라이브뷰 부재)가 전부 검증 불가능한 주장이었다. `snapshot_view` + (세션당 1회) · `label_export`(건수·기간). 거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 + "본 사람"이 아니라 "요청한 사람" 목록이 되어 정작 쓸 수 없다 +- **크론 생존** — 통보·5분 확대·`device_fault` 발행이 전부 한 프로세스에 걸려 있는데, 그게 + 멈추면 화면이 조용해지고 **그 침묵이 "사고 없음"과 구별되지 않았다.** 관리자 화면의 조치는 + 산문 안내 한 줄. 이제 크론이 실행 시각을 남기고 대시보드가 그 나이를 읽는다(5분 기준) +- **증거 보존 보류** — 스냅샷 삭제가 나이만 봤다. 기본 7일인데 중대재해 조사는 몇 주~몇 달이라 + 우리가 내건 목적이 8일째에 사라졌다. admin 만·건별·최대 1년·감사기록과 함께. + 확인 처리로 자동 연장하지 않는다 — 자동이면 사실상 전량 보존이라 7일 약속이 무너진다 +- **증거 정책** — 프레임이 `ergo_report` 빼고 **모든** 이벤트에 붙었다. 체류 상한 초과 포함이라 + 밀폐공간 기본값(30분) 기준 정상 작업자가 규정대로 일하는 동안 주기적으로 사진이 갔다. + 기준 명문화: **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다** +- 마이그레이션 `2026-09-25-evidence-hold-and-view-audit.sql`(README-UPLOAD 10번). + 노조 대응 문서의 "가장 강한 카드가 비어 있다"던 항목이 닫혔고, 7일 원칙의 예외(보류)를 + 정직하게 적었다 +- 산출물 `move-detect-cafe24-1.3.0-20260925.zip` · `move-detect-pi-1.3.0-20260925-aarch64-cp311-hailo8.tar.gz` + diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 29ef966..c2f437e 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -55,6 +55,8 @@ - `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · `backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) · `rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력). + `dispatch_alerts.php` 는 매 실행마다 `move_app_state['dispatch_alerts.last_run']` 을 남기고, + 대시보드(`/api/devices` → `dispatcher`)가 그 나이를 읽어 크론이 멈춘 것을 표시한다. 전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치 - 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` · `GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) · diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index bcd4a74..0111b24 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -1,9 +1,10 @@ # Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리) -> **판정 (2026-09-21, 1.2.1): 적합 — 오프하드웨어 항목 전량 해소.** +> **판정 (2026-09-25, 1.3.0): 적합 — 오프하드웨어 항목 전량 해소.** > 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 + > **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소. > 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다. +> **5차 이해관계자 페르소나 검증(2026-09-25) 4/4** — 열람 감사·크론 생존·증거 보존·증거 정책. > 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐. Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual @@ -532,6 +533,54 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발 --- +## 5차 — 이해관계자 페르소나 검증 (2026-09-25, 1.3.0) + +앞선 네 번은 전부 "이 코드가 제대로 도는가"를 물었다. 이번에는 **"이 기능이 존재해야 할 +이유가 실제로 닿는가"** 를 물었다 — 작업자·노조 대표·안전관리자·공장장·설치기사 5인의 +관점에서 각각 3단논법을 세우고, 전제를 코드로 확인한 뒤 반문으로 깼다. + +5인 중 2인의 논증이 완전히 무너졌고 2인이 부분적으로 무너졌다. 넷 다 같은 모양이었다: +구현도 단위테스트도 초록인데, **그 기능이 지켜야 할 것이 지켜지지 않는 상태.** + +- [x] **S5-1 [B]** 스냅샷 열람·대량 반출이 감사기록에 남지 않았다 (`SnapshotProxy::serve`, + `ExportController::labels`). 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 읽기는 하나도 + 남지 않았다 — 감시 시스템과 안전 시스템을 가르는 선이 정확히 거기고, 나머지 프라이버시 + 설계 전부가 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다. + DONE — `snapshot_view`(세션당 사진 1회) · `label_export`(건수·기간). 거부된 열람은 + 기록하지 않는다(감사 로그가 "본 사람"이 아니라 "요청한 사람" 목록이 되면 쓸 수 없다). + 기록 실패가 사진 열람을 막지 않되 세션 메모에 넣지 않아 다음 요청이 재시도한다. + 테스트 `ReadAuditTest` 8건. + +- [x] **S5-2 [B]** 통보 크론이 멈춘 것을 아무도 감지하지 못했다. 통보·5분 확대·`device_fault` + 발행이 전부 `dispatch_alerts.php` 한 프로세스에 걸려 있는데, 그게 멈추면 화면은 조용해지고 + 그 침묵이 "사고 없음"과 구별되지 않는다. 관리자 화면의 조치는 산문 안내 한 줄이었다. + DONE — 크론이 `move_app_state` 에 실행 시각을 남기고 `/api/devices` 가 그 나이를 읽는다 + (5분 기준). "한 번도 안 돎"과 "멈춤"을 구별한다 — 조치가 다르기 때문이다. 마이그레이션 전 + 배포에서 상태 패널 전체가 죽지 않도록 예외를 삼킨다. 테스트 `DispatcherLivenessTest`. + +- [x] **S5-3 [H]** 스냅샷 삭제가 나이만 봤다(`purgeOlderThan`). 기본 7일인데 중대재해 조사는 + 몇 주~몇 달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. + DONE — `move_events.hold_until`. admin 만, 건별로, 최대 1년, 감사기록과 함께. 확인 처리로 + 자동 연장하지 않는다(자동이면 사실상 전량 보존이 되어 7일 약속이 무너진다). 고아 스냅샷은 + LEFT JOIN 으로 여전히 삭제 대상 — INNER JOIN 이면 영원히 안 지워지는 PIPA 누출이 된다. + 테스트 `EvidenceHoldTest` 8건. + +- [x] **S5-4 [H]** 증거 프레임이 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었다. 체류 상한 + 초과(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 + 일하는 동안 주기적으로 본인 사진이 서버로 갔다 — 위험해서가 아니라 오래 있어서. + DONE — `edge/app.py` `NO_EVIDENCE_TYPES`. 기준은 "시간 경과 자체가 조건인 이벤트에는 + 사진을 붙이지 않는다". 규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이라 유지. + `run_once` 를 실제로 태워 같은 배치에서 한쪽은 증거를 들고 다른 쪽은 안 드는지 본다. + +### 이번 검증에서 확인한 잔여 (코드로 닫을 수 없음) + +- **노조 대표 열람 계정** — 감사기록이 생겼으므로 이제 의미를 갖는다. 계정 발급은 사업주 결정. +- **보관기간 화면 표시** — 설정 파일 안이 아니라 눈에 보이는 곳에. 다음 UI 개정에서. +- **SHA256SUMS 미서명** — `SIGN_KEY` 미설정이라 손상은 잡고 위변조는 못 잡는다. 폐쇄망 USB + 전달 경로의 위협 모델에서는 수용 가능하고 문서에 정직하게 적혀 있다. 결정으로 기록한다. + +--- + ## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능) 코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다. diff --git a/docs/영상보관-노조대응.md b/docs/영상보관-노조대응.md index 7bf9eb5..49d77c6 100644 --- a/docs/영상보관-노조대응.md +++ b/docs/영상보관-노조대응.md @@ -13,7 +13,9 @@ |---|---|---|---| | 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임 = 7.5초**(기본 12fps). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)`, `TARGET_FPS=12` | | 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 3장** — 이벤트 **2.5초 전·1초 전·그 순간**, 얼굴 블러 후 JPEG | `edge/app.py` `SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)` | -| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제** | `Config::snapshotRetentionDays() = 7` | +| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제**. 예외는 아래 '보존 보류' 한 가지 | `Config::snapshotRetentionDays() = 7` | +| 스냅샷 **열람** | 서버 | **누가 언제 어느 사진을 열었는지 감사기록에 남음** | `SnapshotProxy::recordView()`, 감사 동작 `snapshot_view` | +| 기록 **반출** | 서버 | 대량 내보내기도 건수와 함께 기록됨 | `ExportController`, 감사 동작 `label_export` | | 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `Config::eventRetentionDays() = 0` | > **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다 @@ -106,22 +108,24 @@ - **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아 지우거나 고치면 검증에서 드러납니다 - **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음 +- **열람도 기록됩니다** — 누가 언제 어느 사진을 열었는지가 같은 해시 체인에 남습니다. + 쓰기만 남기고 읽기는 안 남기면 "안 봤다"는 주장에 그치는데, 이제 기록으로 답할 수 있습니다 +- **오래 머물렀다는 이유로는 사진이 남지 않습니다** — 체류 상한 초과는 시간 경과만으로 + 발화하므로 증거 프레임을 붙이지 않습니다. 사진이 남는 것은 사람이 쓰러졌거나 위험구역에 + 들어갔거나 2인1조가 깨졌을 때입니다 (`edge/app.py` `NO_EVIDENCE_TYPES`) -### 아직 없는 것 — 보완을 권고합니다 +### 남은 것 — 운영으로 해야 하는 두 가지 -**스냅샷을 누가 열람했는지가 감사기록에 남지 않습니다.** 현재 감사기록은 쓰기 -동작(확인·오탐·구역 변경·설정 변경·로그인)만 기록합니다(`server/src/**` 의 -`audit->append` 호출부 기준). +열람 감사기록은 **구현되었습니다**(2026-09-25). 이 문서가 "가장 강한 카드가 비어 있다"고 +지적했던 항목이고, 이제 누가 언제 어느 사진을 열었는지가 설정 변경과 같은 해시 체인에 +남습니다. 대량 반출도 건수와 함께 기록됩니다. -이것은 노조 협의에서 **가장 강한 카드가 될 수 있는 항목이 비어 있는 것**입니다. -"아무도 함부로 보지 않는다"를 약속이 아니라 **기록으로** 만들 수 있습니다. +코드로 닫을 수 없는 두 가지가 남습니다 — 둘 다 사업주의 결정입니다. -권고 3가지: - -1. **스냅샷 열람을 감사기록에 남긴다** — 누가, 언제, 어느 이벤트의 사진을 열었는지. -2. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** — +1. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** — 감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다. -3. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에. + 기록이 생겼으므로 이제 이 권한이 실제로 의미를 가집니다. +2. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에. --- @@ -150,6 +154,25 @@ 노조에게는 "사진은 일주일이면 사라지고, 남는 것은 몇 시에 무슨 일이 있었다는 기록뿐"이라고 설명할 수 있고, 사업주에게는 보존 의무를 충족한다고 설명할 수 있습니다. +#### 예외 하나 — 사고로 확정된 건의 증거 보존 (2026-09-25 추가) + +중대재해 조사는 몇 주에서 몇 달입니다. 7일 규칙만 있으면 **조사가 시작되기도 전에 사진이 +사라져**, 이 시스템을 도입한 목적("사고 후 원인 분석") 자체가 성립하지 않습니다. + +그래서 **명시적 보존 보류**를 둡니다. 이것이 7일 원칙의 훼손이 아닌 이유: + +| | 내용 | +|---|---| +| 걸 수 있는 사람 | **admin 만** (`manage_compliance`). 현장 운영자는 못 겁니다 | +| 대상 | 보류를 건 **그 사건 한 건**. 전체 보존으로 바뀌지 않습니다 | +| 기간 | 화면 기본 90일, **최대 1년**. 무기한 보류는 아예 불가능합니다 | +| 기록 | 누가 언제 얼마나 걸었는지 · 해제했는지가 감사 체인에 남습니다 | +| 만료 후 | 자동으로 다시 삭제 대상이 됩니다. 사람이 해제하지 않아도 됩니다 | + +노조에는 이렇게 설명할 수 있습니다 — **"전부 지웁니다. 예외는 사고로 확정돼 조사가 걸린 +건뿐이고, 그것도 최대 1년이며, 누가 걸었는지가 기록에 남습니다."** 확인 처리만으로 자동 +연장되지 않는다는 점이 중요합니다: 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다. + ### 5-3. 노조 협의는 이기고 지는 문제가 아니라 절차 문제입니다 **근로자참여 및 협력증진에 관한 법률 제20조**는 **"사업장 내 근로자 감시 설비의 설치"** diff --git a/docs/프로젝트-소개서.html b/docs/프로젝트-소개서.html index ff675ad..90e407c 100644 --- a/docs/프로젝트-소개서.html +++ b/docs/프로젝트-소개서.html @@ -139,7 +139,7 @@ pre code { background: transparent; color: inherit; padding: 0; } 담당자 통보까지 이어지도록 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.

-

버전 1.2.1 · 2026-09-21

+

버전 1.3.0 · 2026-09-25


1. 왜 만들었나

작업장에 설치된 카메라와 현장 경광등.
작업장에 설치된 카메라와 현장 경광등.
diff --git a/docs/프로젝트-소개서.md b/docs/프로젝트-소개서.md index 1f05673..2a489bc 100644 --- a/docs/프로젝트-소개서.md +++ b/docs/프로젝트-소개서.md @@ -6,7 +6,7 @@ > 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지 > 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다. -버전 1.2.1 · 2026-09-21 +버전 1.3.0 · 2026-09-25 --- diff --git a/edge/app.py b/edge/app.py index 1519778..e27cd60 100644 --- a/edge/app.py +++ b/edge/app.py @@ -14,6 +14,27 @@ from edge.events import Event, EventType, severity_for # The server accepts at most 3 snapshots per event (EventValidator), so this is the cap. SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0) +# 사진을 붙이지 않는 유형. +# +# 기준은 "이 사진이 사건의 증거인가, 그 사람이 거기 있었다는 기록인가"이다. 넘어짐·추락·협착은 +# 무슨 일이 일어났는지가 프레임 안에 있고, 단독작업·정원 초과는 규칙 위반 자체가 인원 수라서 +# 사진이 그 수를 증명한다(관리자가 오탐인지 확인하는 수단이기도 하다). 아래 넷은 다르다: +# +# ERGO_REPORT — 카메라 단위 익명 통계. 프레임을 붙이면 통계가 감시 영상이 된다. +# OVERSTAY — 발화 조건이 시간 경과 하나뿐이다. "정상 작업을 오래 했다"는 사건이 아니고, +# 사진이 말해 주는 것은 그게 누구였고 무엇을 입었는지뿐이다. 그 사람이 실제로 +# 쓰러지면 fall/no_motion 이 따로 발화하고 그쪽에는 증거가 붙는다. +# DEVICE_FAULT / MONITORING_DEGRADED — 장치 상태. 사람이 찍힐 이유가 없다. +# +# 이 목록이 짧게 유지되는 것이 중요하다. "정상 근무 중에는 아무것도 남지 않는다"는 우리가 +# 작업자에게 한 약속인데, 시간이 조건인 유형에 사진을 붙이는 순간 그 약속이 거짓이 된다. +NO_EVIDENCE_TYPES = frozenset({ + EventType.ERGO_REPORT, + EventType.OVERSTAY, + EventType.DEVICE_FAULT, + EventType.MONITORING_DEGRADED, +}) + def _head_boxes(persons: list) -> list: # Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed @@ -85,10 +106,8 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f for tr in lost: pool.forget(tr.track_id) if events: - # ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame - # would turn a statistic into surveillance imagery. Capture only when a real incident - # needs evidence, and never attach snapshots to the ergo report itself. - evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT] + # 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다. + evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES] snaps = [] if evidential: # Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end @@ -98,7 +117,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S) snaps = snapshotter.capture(frames, boxes) for e in events: - sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps) + sink.try_send(e, [] if e.event_type in NO_EVIDENCE_TYPES else snaps) return events diff --git a/server/deploy/README-UPLOAD.md b/server/deploy/README-UPLOAD.md index 02cb763..304ed99 100644 --- a/server/deploy/README-UPLOAD.md +++ b/server/deploy/README-UPLOAD.md @@ -96,6 +96,7 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다. | 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 | | 8 | `2026-08-09-safety-platform.sql` | 통보 채널·억제·위험성평가·인수인계·성능 시계열 표, 감사로그 해시체인, 신규 감지 유형 | | 9 | `2026-09-04-fall-from-height.sql` | 추락(`fall_from_height`)·동시 다발 쓰러짐(`multi_down`) 유형 허용 | +| 10 | `2026-09-25-evidence-hold-and-view-audit.sql` | 증거 보존 보류(`hold_until`) + 통보 크론 생존 기록(`move_app_state`) | 1. phpMyAdmin → 해당 DB → **SQL** 탭 2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다 diff --git a/server/migrations/2026-09-25-evidence-hold-and-view-audit.sql b/server/migrations/2026-09-25-evidence-hold-and-view-audit.sql new file mode 100644 index 0000000..254528b --- /dev/null +++ b/server/migrations/2026-09-25-evidence-hold-and-view-audit.sql @@ -0,0 +1,36 @@ +-- 1.3.0 — 증거 보존(legal hold) + 디스패처 생존 기록 +-- +-- 적용 조건: 2026-09-04-fall-from-height.sql 이후. +-- +-- 왜 이 두 개가 한 마이그레이션인가: 둘 다 "조용한 실패"를 드러내는 같은 종류의 장치다. +-- +-- 1) move_events.hold_until — 스냅샷 보존 보류 +-- 스냅샷은 보존기간이 지나면 나이만 보고 지워졌다. 중대재해 조사는 몇 주에서 몇 달인데 +-- 사진은 7일이면 사라진다. 우리가 노조와 고객에게 말해 온 목적("사고 후 원인 분석")이 +-- 8일째에 증발하는 상태였다. 보류는 명시적 조작이어야 하고(자동 연장은 결국 전량 보존이 +-- 되어 프라이버시 약속을 무너뜨린다) 감사에 남아야 한다. +-- +-- 2) move_app_state — 크론이 마지막으로 돈 시각 +-- 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 +-- 크론이 죽으면 아무도 판정하지 않는다. 통보·확대·장치 이상 발행이 전부 한 프로세스에 +-- 걸려 있는데, 화면에는 "크론으로 실행 중이어야 합니다"라는 안내문만 있었다. +-- 감시자의 생존은 감시자가 아닌 쪽(운영자의 브라우저)이 읽어야 하므로, 크론이 자기 +-- 실행 시각을 남기고 대시보드가 그 나이를 본다. + +ALTER TABLE move_events + ADD COLUMN hold_until BIGINT UNSIGNED NULL AFTER false_positive_at; + +-- 보존 정리는 "이 스냅샷의 이벤트가 보류 중인가"를 건마다 묻는다. 보류는 드물기 때문에 +-- 전체 스캔이 아니라 보류된 것만 찾는 인덱스가 맞다. +CREATE INDEX ix_events_hold ON move_events (hold_until); + +CREATE TABLE move_app_state ( + k VARCHAR(64) NOT NULL, + v VARCHAR(255) NOT NULL, + updated_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (k) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 검증 쿼리 — 아래 둘이 각각 1행, 0행을 내면 성공입니다. +-- SHOW COLUMNS FROM move_events LIKE 'hold_until'; +-- SELECT * FROM move_app_state; diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 1281d17..f08972a 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -992,6 +992,7 @@ }) .then(function (data) { if (data && data.storage) renderStorage(data.storage); + if (data) renderDispatcher(data.dispatcher); if (data && Array.isArray(data.devices)) { deviceList = data.devices; devicesLoaded = true; @@ -1036,6 +1037,35 @@ + '보존기간 설정을 줄이거나 용량을 확보하세요.')); } + // --- notification dispatcher liveness ----------------------------------------------- + // 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 크론이 + // 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이 전부 그 한 프로세스에 + // 걸려 있어서, 멈춘 순간부터 이 화면은 조용해지고 그 침묵은 "사고 없음"과 똑같이 보인다. + // 감시자의 생존은 감시자가 아닌 쪽이 읽어야 해서, 그 판정이 여기 있다. + var dispatchWarn = document.getElementById('dispatch-warn'); + + function renderDispatcher(d) { + if (!dispatchWarn) return; + if (!d || !d.stale) { dispatchWarn.hidden = true; return; } + dispatchWarn.hidden = false; + dispatchWarn.textContent = ''; + var never = d.last_run_at === null || d.last_run_at === undefined; + dispatchWarn.appendChild(spanWith('warn-lead', never ? '통보가 설정되지 않았습니다' : '통보가 멈췄습니다')); + dispatchWarn.appendChild(document.createTextNode(never + ? ' — 통보 디스패처가 한 번도 실행된 적이 없습니다. 크론에 tools/dispatch_alerts.php 를 1분 주기로 등록하세요. ' + + '등록 전까지 메일·웹훅·5분 미확인 확대·장치 이상 알림이 나가지 않습니다 (현장 경광등은 영향 없음).' + : ' — 마지막 실행 ' + fmtAgo(d.seconds_since) + ' 전 (기준 ' + + Math.round(d.stale_after_s / 60) + '분). 이 상태에서는 화면이 조용한 것이 "사고 없음"이라는 뜻이 아닙니다. ' + + '메일·웹훅·미확인 확대·장치 이상 알림이 전부 멈춰 있습니다 (현장 경광등은 영향 없음).')); + } + + function fmtAgo(s) { + if (typeof s !== 'number') return '?'; + if (s < 120) return s + '초'; + if (s < 7200) return Math.floor(s / 60) + '분'; + return Math.floor(s / 3600) + '시간'; + } + // --- bulk acknowledge ---------------------------------------------------------------- // For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall // is exactly the row a human must look at, and a control that can clear one in a sweep is a diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index d7ebe1a..fc35905 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -158,11 +158,13 @@ .catch(function () { /* the POST surfaces it as a 403 */ }); } - function post(path) { - return fetch(B + '/events/' + encodeURIComponent(id) + path, { - method: 'POST', - headers: { 'X-Csrf-Token': csrfToken || '' } - }).then(function (res) { + function post(path, body) { + var opt = { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } }; + if (body !== undefined) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + return fetch(B + '/events/' + encodeURIComponent(id) + path, opt).then(function (res) { if (res.status === 401) { window.location.href = B + '/login'; return null; } if (!res.ok) throw new Error('action_failed:' + res.status); return res; @@ -242,6 +244,60 @@ actionsBox.appendChild(fp); } + // --- 증거 보존 보류 ------------------------------------------------------------------- + // 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙 자체는 + // 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진이 사라졌다. + // + // renderActions 와 분리한 이유: 저쪽은 확인·오탐이 끝나면 조기 반환한다. 보류가 필요한 + // 상황은 바로 그 "이미 확인된 진짜 사고"라서, 같은 함수에 두면 정작 필요할 때 안 보인다. + var holdBox = document.getElementById('detail-hold'); + var HOLD_DAYS = 90; + + function renderHold() { + if (!holdBox || !currentEvent) return; + if (window.APP_ROLE !== 'admin') { holdBox.hidden = true; return; } + holdBox.hidden = false; + holdBox.textContent = ''; + + var until = currentEvent.hold_until; + var held = typeof until === 'number' && until * 1000 > Date.now(); + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = held ? 'fp-btn' : 'detail-link-btn'; + btn.textContent = held ? '보존 보류 해제' : '증거 보존 (' + HOLD_DAYS + '일)'; + btn.setAttribute('aria-label', held + ? '증거 보존 보류를 해제합니다. 이후 보존기간이 지난 스냅샷은 정리 대상이 됩니다.' + : '이 사건의 스냅샷을 ' + HOLD_DAYS + '일간 보존기간 정리에서 제외합니다.'); + btn.addEventListener('click', function () { + btn.disabled = true; + post('/hold', { days: held ? 0 : HOLD_DAYS }) + .then(function (res) { return res ? res.json() : null; }) + .then(function (d) { + if (!d) return; + currentEvent.hold_until = d.hold_until; + renderHold(); + renderMeta(); + }) + .catch(function () { + btn.disabled = false; + var p = document.createElement('p'); + p.className = 'detail-action-note is-error'; + p.textContent = '보존 보류 처리에 실패했습니다.'; + holdBox.appendChild(p); + }); + }); + holdBox.appendChild(btn); + + var note = document.createElement('p'); + note.className = 'detail-action-note'; + note.textContent = held + ? '보존 보류 중 — ' + new Date(until * 1000).toLocaleDateString('ko-KR') + + '까지 이 사건의 사진이 자동 삭제되지 않습니다.' + : '조사가 진행 중이면 보존을 걸어두세요. 걸지 않으면 보존기간이 지난 사진은 자동으로 지워집니다.'; + holdBox.appendChild(note); + } + function renderMeta() { var ev = currentEvent || {}; meta.textContent = ''; @@ -374,6 +430,7 @@ renderHead(); renderMeta(); renderActions(); + renderHold(); renderLinks(); renderSnapshots(data.snapshots); } diff --git a/server/public/index.php b/server/public/index.php index 1f6a30e..19c5b71 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -42,7 +42,8 @@ use App\Auth\PhpSessionStore; use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository, PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository, PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository, - PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository}; + PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository, + PdoAppStateRepository}; use App\Ingest\IngestController; use App\Api\{ConfigController, HeartbeatController}; use App\Auth\{AuthController, CsrfController}; @@ -93,6 +94,7 @@ $handovers = new PdoHandoverRepository($pdo); $risks = new PdoRiskControlRepository($pdo); $metrics = new PdoDeviceMetricRepository($pdo); $actions = new PdoActionRepository($pdo); +$appState = new PdoAppStateRepository($pdo); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); $config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts); @@ -101,13 +103,13 @@ $auth = new AuthController($users, $audit, $rate); $csrfCtl = new CsrfController(); $list = new EventListController($events, $snaps, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess); -$proxy = new SnapshotProxy($snaps, $events, $sess); +$proxy = new SnapshotProxy($snaps, $events, $sess, $audit); $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); -$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg); +$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg, $appState); $heatmap = new HeatmapController($events, $sess); $fpStats = new FpStatsController($events, $sess); $report = new ReportController($events, $sess); -$export = new ExportController($events, $snaps, $sess); +$export = new ExportController($events, $snaps, $sess, $audit); $actionsCtl = new ActionsController($actions, $events, $sess); $adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess); $suppressCtl = new SuppressionController($suppressions, $events, $devices, $audit, $sess); @@ -132,6 +134,7 @@ $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); $r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p)); +$r->add('POST', '/events/{id}/hold', fn(Request $q, array $p) => $detail->hold($q, $p)); $r->add('GET', '/api/zone-presets', fn(Request $q) => $zoneCtl->presets($q)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); $r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p)); diff --git a/server/schema.sql b/server/schema.sql index f074e48..84ea29d 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -151,6 +151,11 @@ CREATE TABLE move_events ( acknowledged_at BIGINT UNSIGNED NULL, false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning) false_positive_at BIGINT UNSIGNED NULL, + -- 증거 보존 보류. 이 시각까지는 보존기간 정리가 이 사건의 스냅샷을 지우지 않는다. + -- 중대재해 조사는 몇 주~몇 달인데 스냅샷 기본 보존은 7일이라, 보류가 없으면 "사고 후 + -- 원인 분석"이라는 목적 자체가 8일째에 사라진다. 자동 연장이 아니라 명시적 조작으로만 + -- 걸리고(자동이면 결국 전량 보존이 되어 프라이버시 약속이 무너진다) 감사에 남는다. + hold_until BIGINT UNSIGNED NULL, received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) PRIMARY KEY (event_id), -- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side @@ -160,6 +165,7 @@ CREATE TABLE move_events ( KEY ix_events_occurred_at (occurred_at), KEY ix_events_severity_occurred_at (severity, occurred_at), KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b) + KEY ix_events_hold (hold_until), -- 보존 정리가 보류된 건만 빠르게 걸러낸다 KEY ix_events_device (device_id), KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera CONSTRAINT chk_events_type CHECK (event_type IN ( @@ -414,6 +420,23 @@ CREATE TABLE move_device_metrics ( KEY ix_device_metrics_device_ts (device_id, ts) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- 감시자의 생존 기록. +-- +-- 죽은 노드는 스스로 신고할 수 없어서 크론(tools/dispatch_alerts.php)이 하트비트의 부재로 +-- 판정한다. 그런데 그 크론이 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이 +-- 전부 그 한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과 +-- 구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 하므로, 크론이 매 실행마다 자기 +-- 시각을 여기 남기고 대시보드(/api/devices)가 그 나이를 본다. +-- +-- 범용 키-값이지만 용도를 넓히지 말 것: 설정은 config.php, 임계값은 move_thresholds 가 있다. +-- 여기 들어갈 것은 "프로세스가 언제 살아 있었는가" 뿐이다. +CREATE TABLE move_app_state ( + k VARCHAR(64) NOT NULL, + v VARCHAR(255) NOT NULL, + updated_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (k) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- =========================================================================== -- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above): -- do NOT copy the statements below by hand — they are SQL comments and will not diff --git a/server/src/Dashboard/DeviceStatusController.php b/server/src/Dashboard/DeviceStatusController.php index e0e4178..af5907b 100644 --- a/server/src/Dashboard/DeviceStatusController.php +++ b/server/src/Dashboard/DeviceStatusController.php @@ -6,7 +6,7 @@ use App\Config; use App\Http\{Request, Response}; use App\Auth\SessionStore; use App\Security\SiteScope; -use App\Repository\{DeviceRepository, DeviceMetricRepository}; +use App\Repository\{DeviceRepository, DeviceMetricRepository, AppStateRepository}; /** * GET /api/devices — device/camera health for the dashboard's status panel. The edge already @@ -27,13 +27,22 @@ final class DeviceStatusController // yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls). private const OFFLINE_AFTER_S = 120; + /** + * 통보 디스패처(크론)가 이 시간 넘게 자기 실행을 남기지 않았으면 멈춘 것으로 본다. + * 크론은 1분 주기이므로 5배 여유 — 공유호스팅에서 한두 주기가 밀리는 것과 진짜로 + * 죽은 것을 가른다. 이 판정이 필요한 이유는 크론이 죽으면 통보·5분 확대·장치 이상 + * 발행이 한꺼번에 조용히 멈추고, 그 침묵이 "사고 없음"과 똑같이 보이기 때문이다. + */ + private const DISPATCHER_STALE_AFTER_S = 300; + /** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */ private const SERIES_WINDOW_S = 86400; private const SERIES_LIMIT = 3000; public function __construct( private DeviceRepository $devices, private SessionStore $sess, - private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null + private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null, + private ?AppStateRepository $state = null ) {} public function json(Request $req, int $now): Response @@ -63,7 +72,11 @@ final class DeviceStatusController ]; }, $this->devices->listStatus($site)); - return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200); + return Response::json([ + 'devices' => $devices, + 'storage' => $this->storage(), + 'dispatcher' => $this->dispatcher($now), + ], 200); } /** @@ -122,4 +135,35 @@ final class DeviceStatusController 'low' => $freeMb < $minFreeMb, ]; } + + /** + * 통보 디스패처의 생존. + * + * 이 판정만 브라우저가 읽어야 하는 이유: 크론이 죽으면 크론이 발행하는 어떤 경고도 + * 나오지 않는다. device_fault 는 하트비트의 부재로 노드를 판정하지만, 그 판정을 하는 + * 주체가 바로 이 크론이다. 그래서 크론의 부재는 크론 밖에서만 보인다. + * + * never = 아직 한 번도 안 돌았다(설치 직후 크론 미등록). 이걸 stale 과 구별하는 이유는 + * 조치가 다르기 때문이다 — 전자는 등록하는 것이고 후자는 왜 멈췄는지 보는 것이다. + * + * @return array{last_run_at:int|null, seconds_since:int|null, stale:bool, stale_after_s:int} + */ + private function dispatcher(int $now): array + { + $row = null; + try { + $row = $this->state?->get('dispatch_alerts.last_run'); + } catch (\Throwable) { + // 표가 아직 없는(마이그레이션 전) 배포에서 상태 패널 전체가 죽지 않게 한다. + // 그 경우 "한 번도 안 돌았다"로 보이고, 그것이 실제로 맞는 상태다. + } + $last = $row !== null ? (int)$row['updated_at'] : null; + $since = $last !== null ? max(0, $now - $last) : null; + return [ + 'last_run_at' => $last, + 'seconds_since' => $since, + 'stale' => $since === null || $since > self::DISPATCHER_STALE_AFTER_S, + 'stale_after_s' => self::DISPATCHER_STALE_AFTER_S, + ]; + } } diff --git a/server/src/Dashboard/EventDetailController.php b/server/src/Dashboard/EventDetailController.php index dce8805..275449e 100644 --- a/server/src/Dashboard/EventDetailController.php +++ b/server/src/Dashboard/EventDetailController.php @@ -9,6 +9,10 @@ use App\Repository\{EventRepository, SnapshotRepository, AuditRepository}; final class EventDetailController { + /** 1년. 이보다 긴 보존이 필요하면 사건을 따로 내보내는 것이 맞지, 사진을 계속 + * 들고 있는 것이 맞지 않다. 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. */ + private const MAX_HOLD_DAYS = 365; + public function __construct( private EventRepository $events, private SnapshotRepository $snaps, private AuditRepository $audit, private SessionStore $sess @@ -68,4 +72,44 @@ final class EventDetailController $this->audit->append($user, 'false_positive', "event:$id", [], []); return Response::json(['ok' => true], 200); } + + /** + * POST /events/{id}/hold — 증거 보존 보류 (본문: {"days": 1..365} 또는 {"days": 0} 해제). + * + * 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙 + * 자체는 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진 증거가 + * 사라졌다. "사고 후 원인 분석" 이라는 우리가 내건 목적이 8일째에 스스로 지워지는 상태. + * + * 자동 연장이 아닌 이유는 확인(acknowledge)에 얹으면 사실상 전량 보존이 되어 프라이버시 + * 약속이 무너지기 때문이고, admin(manage_compliance)으로 막은 이유는 보존기간 연장이 + * 운영 편의가 아니라 규정 판단이기 때문이다. 누가 언제 얼마나 늘렸는지가 감사에 남는다 — + * 노조에 "전부 지웁니다, 단 사고 확정 건은 조사 종료까지, 기록과 함께" 라고 말할 수 있는 + * 근거가 이 한 행이다. + */ + public function hold(Request $req, array $params): Response + { + if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); + if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) { + return Response::json(['error' => 'forbidden'], 403); + } + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } + $id = (int)($params['id'] ?? 0); + $event = $this->events->find($id); + if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404); + + $raw = $req->json()['days'] ?? null; + if (!is_numeric($raw)) return Response::json(['error' => 'bad_days'], 400); + $days = (int)$raw; + if ($days < 0 || $days > self::MAX_HOLD_DAYS) return Response::json(['error' => 'bad_days'], 400); + + $until = $days === 0 ? null : time() + $days * 86400; + if (!$this->events->setHold($id, $until)) return Response::json(['error' => 'not_found'], 404); + + $user = (string)$this->sess->get('username'); + $this->audit->append($user, $until === null ? 'evidence_hold_release' : 'evidence_hold', + "event:$id", ['hold_until' => $event['hold_until'] ?? null], ['hold_until' => $until]); + return Response::json(['ok' => true, 'hold_until' => $until], 200); + } } diff --git a/server/src/Dashboard/ExportController.php b/server/src/Dashboard/ExportController.php index 701a8c5..c19cc52 100644 --- a/server/src/Dashboard/ExportController.php +++ b/server/src/Dashboard/ExportController.php @@ -4,7 +4,7 @@ namespace App\Dashboard; use App\Http\{Request, Response}; use App\Auth\{SessionStore, Rbac}; -use App\Repository\{EventRepository, SnapshotRepository}; +use App\Repository\{EventRepository, SnapshotRepository, AuditRepository}; /** * GET /api/export/labels?days=N -> JSONL download. @@ -29,7 +29,7 @@ final class ExportController public function __construct( private EventRepository $events, private SnapshotRepository $snaps, - private SessionStore $sess + private SessionStore $sess, private ?AuditRepository $audit = null ) {} public function labels(Request $req): Response @@ -69,6 +69,14 @@ final class ExportController 'snapshots' => $uuids[(int)$r['event_id']] ?? [], ], JSON_UNESCAPED_UNICODE); } + // 대량 반출은 남긴다. 이 한 번의 호출이 최대 2만 건의 사건과 그에 딸린 스냅샷 uuid + // 전부를 파일 하나로 내보내는데, 기록이 없으면 "지난달에 우리 현장 데이터가 통째로 + // 나간 적 있습니까"에 답할 수 없다. 열람 감사(snapshot_view)와 짝이며, 이쪽은 드물게 + // 일어나므로 무조건 한 행씩 남긴다. 반출 사실을 남기는 것이지 반출을 막는 장치가 + // 아니므로, 기록이 실패해도 파일은 내보낸다. + $this->audit?->append((string)$this->sess->get('username'), 'label_export', + 'export:labels', [], ['days' => $days, 'rows' => count($lines), 'site' => $site]); + $body = implode("\n", $lines) . ($lines ? "\n" : ''); return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [ 'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"', diff --git a/server/src/Dashboard/SnapshotProxy.php b/server/src/Dashboard/SnapshotProxy.php index ae4bbc4..46731c1 100644 --- a/server/src/Dashboard/SnapshotProxy.php +++ b/server/src/Dashboard/SnapshotProxy.php @@ -5,12 +5,19 @@ namespace App\Dashboard; use App\Http\{Request, Response}; use App\Auth\SessionStore; use App\Security\SiteScope; -use App\Repository\{SnapshotRepository, EventRepository}; +use App\Repository\{SnapshotRepository, EventRepository, AuditRepository}; final class SnapshotProxy { + /** + * 세션당 기억할 열람 uuid 수. 넘으면 오래된 것부터 버리고, 버려진 uuid 를 다시 보면 + * 감사에 한 줄 더 남는다 — 넘치면 **덜 기록**이 아니라 **더 기록**하는 방향으로 틀어진다. + */ + private const VIEWED_MEMO_CAP = 500; + public function __construct( - private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess + private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess, + private ?AuditRepository $audit = null ) {} public function serve(Request $req, array $params): Response @@ -30,6 +37,7 @@ final class SnapshotProxy return Response::json(['error' => 'not_found'], 404); } } + $this->recordView($uuid); // A snapshot is immutable per uuid, so let the browser cache it. Without this the event // list — which rebuilds every on each 5s poll — re-downloads every // visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open @@ -38,4 +46,42 @@ final class SnapshotProxy return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200, ['Cache-Control' => 'private, max-age=86400, immutable']); } + + /** + * 작업자 사진을 누가 언제 봤는지 남긴다. + * + * 이 시스템은 쓰기를 전부 감사한다 — 임계값 한 번 바꾼 것까지 해시 체인에 들어간다. + * 그런데 읽기는 하나도 남지 않았다. 관리자가 조합원 사진 200장을 열어봐도 흔적이 없고, + * 교섭에서 "안 봤습니다"는 증명이 아니라 주장이다. 감시 시스템과 안전 시스템을 가르는 + * 선이 정확히 여기라서, 나머지 프라이버시 설계(RAM 링버퍼·전송 전 블러·라이브뷰 부재)가 + * 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다. + * + * 세션 안에서 같은 uuid 는 한 번만 기록한다. 목록 화면은 5초마다 를 다시 그리고 + * (브라우저 캐시가 대부분 막지만 시크릿 창·캐시 해제는 그대로 들어온다), 감사 추가는 + * 체인 꼬리에 행 잠금을 잡는 작업이라 장당 한 행씩 쓰면 공유호스팅에서 직렬화된다. + * "이 로그인 세션에서 이 사진을 봤다"는 노조가 묻는 질문에 답하기 충분하다. + * + * 기록 실패가 사진 열람을 막지는 않는다 — 감사가 안전 기능을 죽이면 안 된다. 다만 그 + * 경우 메모에 넣지 않아서 다음 요청이 다시 시도한다. + */ + private function recordView(string $uuid): void + { + if ($this->audit === null) return; + $seen = $this->sess->get('snap_viewed'); + $seen = is_array($seen) ? $seen : []; + if (in_array($uuid, $seen, true)) return; + + try { + $this->audit->append((string)$this->sess->get('username'), 'snapshot_view', + "snapshot:$uuid", [], []); + } catch (\Throwable) { + return; // 메모에 안 넣는다 → 다음 요청에서 재시도 + } + + $seen[] = $uuid; + if (count($seen) > self::VIEWED_MEMO_CAP) { + $seen = array_slice($seen, -self::VIEWED_MEMO_CAP); + } + $this->sess->set('snap_viewed', $seen); + } } diff --git a/server/src/Repository/AppStateRepository.php b/server/src/Repository/AppStateRepository.php new file mode 100644 index 0000000..554dfc4 --- /dev/null +++ b/server/src/Repository/AppStateRepository.php @@ -0,0 +1,18 @@ +pdo->prepare('SELECT v, updated_at FROM move_app_state WHERE k = ?'); + $st->execute([$key]); + $row = $st->fetch(); + if ($row === false) return null; + return ['value' => (string)$row['v'], 'updated_at' => (int)$row['updated_at']]; + } + + public function set(string $key, string $value, int $now): void + { + // upsert 문법이 엔진마다 다르다. sqlite 통합 테스트가 납품되는 실제 SQL 을 돌리므로 + // 둘 다 맞아야 한다 — MySQL 은 ON DUPLICATE KEY, sqlite 는 ON CONFLICT. + $mysql = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite'; + $sql = $mysql + ? 'INSERT INTO move_app_state (k, v, updated_at) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE v = VALUES(v), updated_at = VALUES(updated_at)' + : 'INSERT INTO move_app_state (k, v, updated_at) VALUES (?, ?, ?) + ON CONFLICT(k) DO UPDATE SET v = excluded.v, updated_at = excluded.updated_at'; + $this->pdo->prepare($sql)->execute([$key, $value, $now]); + } +} diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index b73981d..7fc7fff 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -126,6 +126,21 @@ final class PdoEventRepository implements EventRepository return $row === false ? null : self::mapRow($row); } + public function setHold(int $id, ?int $until): bool + { + // acknowledge() 와 같은 이유로 존재 확인을 따로 한다 — MySQL PDO 는 기본적으로 + // '변경된 행'을 세므로, 같은 값으로 다시 보류하면 rowCount() 가 0 이 된다. + $exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1'); + $exists->execute([$id]); + if ($exists->fetchColumn() === false) return false; + + $st = $this->pdo->prepare('UPDATE move_events SET hold_until = ? WHERE event_id = ?'); + $st->bindValue(1, $until, $until === null ? PDO::PARAM_NULL : PDO::PARAM_INT); + $st->bindValue(2, $id, PDO::PARAM_INT); + $st->execute(); + return true; + } + public function acknowledge(int $id, string $user): bool { // Existence is checked separately (rather than trusting UPDATE's diff --git a/server/src/Repository/Pdo/PdoSnapshotRepository.php b/server/src/Repository/Pdo/PdoSnapshotRepository.php index 3cbcc04..5dcc213 100644 --- a/server/src/Repository/Pdo/PdoSnapshotRepository.php +++ b/server/src/Repository/Pdo/PdoSnapshotRepository.php @@ -121,16 +121,28 @@ final class PdoSnapshotRepository implements SnapshotRepository return $id === false ? null : (int)$id; } - public function purgeOlderThan(int $cutoff, int $limit = 500): int + public function purgeOlderThan(int $cutoff, int $limit = 500, ?int $now = null): int { + $now ??= time(); // Bounded batch: a single request must not stall unlinking thousands of files on a // shared host. Repeated ingests drain the backlog. Files first, then rows — an orphan // row (file gone, row left) is harmless; an orphan file (row gone, file left) is the // PIPA leak we are closing, so never delete the row before its file. + // 보존 보류(legal hold)된 사건의 스냅샷은 나이가 지나도 남긴다. 보류가 없던 동안 + // 이 삭제는 나이 하나만 봤고, 그래서 중대재해 조사가 시작되기도 전에(스냅샷 기본 + // 7일) 사진 증거가 사라졌다 — 우리가 내세운 목적이 스스로 지워지는 상태였다. + // LEFT JOIN 인 이유: 이벤트 행이 먼저 정리된 고아 스냅샷은 보류가 아니라 잔여물이고, + // 그건 지우는 쪽이 맞다(hold_until 이 NULL 로 나와 자연히 대상이 된다). $sel = $this->pdo->prepare( - 'SELECT snapshot_id, file_uuid FROM move_event_snapshots WHERE created_at < ? LIMIT ?'); + 'SELECT s.snapshot_id, s.file_uuid + FROM move_event_snapshots s + LEFT JOIN move_events e ON e.event_id = s.event_id + WHERE s.created_at < ? + AND (e.hold_until IS NULL OR e.hold_until <= ?) + LIMIT ?'); $sel->bindValue(1, $cutoff, PDO::PARAM_INT); - $sel->bindValue(2, $limit, PDO::PARAM_INT); + $sel->bindValue(2, $now, PDO::PARAM_INT); + $sel->bindValue(3, $limit, PDO::PARAM_INT); $sel->execute(); $rows = $sel->fetchAll(); if (!$rows) { diff --git a/server/src/Repository/SnapshotRepository.php b/server/src/Repository/SnapshotRepository.php index a044929..4134008 100644 --- a/server/src/Repository/SnapshotRepository.php +++ b/server/src/Repository/SnapshotRepository.php @@ -23,7 +23,9 @@ interface SnapshotRepository public function latestForCamera(string $cameraId): ?array; /** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention: - * worker images must not be kept forever. Returns the number purged. */ + * worker images must not be kept forever. Returns the number purged. + * 증거 보존 보류(move_events.hold_until)가 걸린 사건의 스냅샷은 나이와 무관하게 남긴다 — + * 조사가 끝나기 전에 증거가 사라지면 이 제품의 목적 자체가 사라진다. */ public function purgeOlderThan(int $cutoff): int; /** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order — diff --git a/server/src/View/event_detail.php b/server/src/View/event_detail.php index d90a6e2..e22b3de 100644 --- a/server/src/View/event_detail.php +++ b/server/src/View/event_detail.php @@ -12,6 +12,7 @@ render_header('이벤트 상세'); +