append-only 방어 트리거 · 파티션 관리
-- =====================================================================
-- nestpay MariaDB 루틴 (routines.sql) - 전수 감사(2026-07-21) 반영판
-- 방침(유지보수 최우선): 비즈니스 로직은 전부 Java. DB 루틴은 두 종류만.
-- 1) 불변식 방어 트리거 - 로직 없음, "금지"만.
-- 2) 파티션 수명 관리(생성·보존 DROP) - DB가 스스로 해야 자연스러운 유일한 운영 루틴.
-- 1차 방어는 권한(GRANT), 트리거는 root/DBA 실수까지 막는 2차 방어선.
-- =====================================================================
DELIMITER $$
-- ---------------------------------------------------------------------
-- 1. append-only 방어 트리거 (#29) - 원장·감사·접근기록·증거 로그
-- ---------------------------------------------------------------------
CREATE TRIGGER trg_ledger_no_update BEFORE UPDATE ON ledger_entries FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger_entries is append-only: UPDATE forbidden'; END$$
CREATE TRIGGER trg_ledger_no_delete BEFORE DELETE ON ledger_entries FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ledger_entries is append-only: DELETE forbidden'; END$$
CREATE TRIGGER trg_txn_no_delete BEFORE DELETE ON transactions FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'transactions is append-only: DELETE forbidden'; END$$
-- transactions UPDATE 화이트리스트: status·confirmed_at·bank_tran_ref·fail_reason만 가변.
-- 금액·유형·참조·스냅샷·증빙 컬럼 전부 불변 (감사 보완: 스냅샷 변조 차단)
CREATE TRIGGER trg_txn_immutable_columns BEFORE UPDATE ON transactions FOR EACH ROW
BEGIN
IF NEW.amount <> OLD.amount OR NEW.type <> OLD.type OR NEW.subtype <> OLD.subtype
OR NEW.fee_amount <> OLD.fee_amount OR NEW.txn_uid <> OLD.txn_uid
OR NEW.initiator_type <> OLD.initiator_type OR NEW.initiator_id <> OLD.initiator_id
OR NOT (NEW.idempotency_key <=> OLD.idempotency_key)
OR NOT (NEW.card_id <=> OLD.card_id)
OR NOT (NEW.counterparty_card_id <=> OLD.counterparty_card_id)
OR NOT (NEW.merchant_id <=> OLD.merchant_id)
OR NOT (NEW.related_txn_id <=> OLD.related_txn_id)
OR NOT (NEW.fee_rate_snap <=> OLD.fee_rate_snap)
OR NOT (NEW.fee_fixed_snap <=> OLD.fee_fixed_snap)
OR NOT (NEW.vat_amount <=> OLD.vat_amount)
OR NOT (NEW.cancelable_until <=> OLD.cancelable_until)
OR NOT (NEW.scheduled_at <=> OLD.scheduled_at)
OR NOT (NEW.bank_account_id <=> OLD.bank_account_id)
OR NOT (NEW.pg_order_id <=> OLD.pg_order_id)
OR NOT (NEW.memo <=> OLD.memo)
OR NEW.created_at <> OLD.created_at THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'transactions: only status/confirmed_at/bank_tran_ref/fail_reason may change';
END IF;
END$$
CREATE TRIGGER trg_audit_no_update BEFORE UPDATE ON audit_logs FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit_logs is append-only'; END$$
CREATE TRIGGER trg_audit_no_delete BEFORE DELETE ON audit_logs FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit_logs is append-only'; END$$
-- 접근기록·증거 로그 방어 대칭화 (감사 보완: security-5/8)
CREATE TRIGGER trg_pii_no_update BEFORE UPDATE ON pii_access_logs FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'pii_access_logs is append-only'; END$$
CREATE TRIGGER trg_pii_no_delete BEFORE DELETE ON pii_access_logs FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'pii_access_logs is append-only (5yr partition drop only)'; END$$
CREATE TRIGGER trg_login_no_update BEFORE UPDATE ON login_histories FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'login_histories is append-only'; END$$
CREATE TRIGGER trg_reject_no_update BEFORE UPDATE ON reject_logs FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'reject_logs is append-only'; END$$
CREATE TRIGGER trg_extapi_no_update BEFORE UPDATE ON external_api_logs FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'external_api_logs is append-only'; END$$
-- 입금 통지: 상태 전이 컬럼만 가변, 증거 원문 불변 (감사 보완: security-9)
CREATE TRIGGER trg_notice_immutable BEFORE UPDATE ON deposit_notices FOR EACH ROW
BEGIN
IF NOT (NEW.raw_text <=> OLD.raw_text) OR NEW.dedup_key <> OLD.dedup_key
OR NOT (NEW.bank_tran_ref <=> OLD.bank_tran_ref)
OR NOT (NEW.parsed_amount <=> OLD.parsed_amount)
OR NOT (NEW.parsed_name <=> OLD.parsed_name)
OR NOT (NEW.identifier_value <=> OLD.identifier_value)
OR NEW.received_at <> OLD.received_at THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'deposit_notices: only status/matched_txn_id may change';
END IF;
END$$
CREATE TRIGGER trg_notice_no_delete BEFORE DELETE ON deposit_notices FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'deposit_notices is append-only (archive runbook only)'; END$$
-- ---------------------------------------------------------------------
-- 2. 로트 방어: 감소만 허용, 속성 불변
-- 복원(취소·출금실패·선물반환)은 UPDATE 증가가 아니라 "신규 로트 생성
-- (origin_lot_id 참조·lot_type·expires_at 승계)" 방식 - queries.sql 0장 규약
-- ---------------------------------------------------------------------
CREATE TRIGGER trg_lot_decrease_only BEFORE UPDATE ON point_lots FOR EACH ROW
BEGIN
IF NEW.amount_remaining > OLD.amount_remaining OR NEW.amount_remaining < 0
OR NEW.amount_init <> OLD.amount_init OR NEW.expires_at <> OLD.expires_at
OR NEW.lot_type <> OLD.lot_type OR NEW.wallet_id <> OLD.wallet_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'point_lots: remaining may only decrease; attributes immutable';
END IF;
END$$
DELIMITER ;
-- ---------------------------------------------------------------------
-- 3. 파티션 자동 확장 (감사 교정: 대상 = V2 실제 파티션 7종과 일치,
-- 테이블별 실패 격리 + 신규 파티션 ANALYZE + p_max 적체 감시는 11장 대사)
-- ---------------------------------------------------------------------
DELIMITER $$
CREATE PROCEDURE sp_extend_month_partitions()
BEGIN
DECLARE next_edge DATE DEFAULT DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL 2 MONTH), '%Y-%m-01');
DECLARE pname VARCHAR(10) DEFAULT DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), 'p%Y%m');
DECLARE tbl VARCHAR(64);
DECLARE done INT DEFAULT 0;
-- 하드코딩 대신 p_max 실존 파티션 테이블 자동 선별 (감사 교정: 목록 불일치 재발 방지)
DECLARE cur CURSOR FOR
SELECT DISTINCT p.table_name FROM information_schema.partitions p
WHERE p.table_schema = DATABASE() AND p.partition_name = 'p_max';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO tbl;
IF done = 1 THEN LEAVE read_loop; END IF;
BEGIN
-- 테이블 단위 실패 격리: 오류는 app_error_logs에 남기고 다음 테이블 진행
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION
INSERT INTO app_error_logs (severity, source, error_code, message, created_at)
VALUES ('CRITICAL', 'BATCH', 'PARTITION_EXTEND_FAIL', CONCAT('table=', tbl, ' pname=', pname), NOW());
IF NOT EXISTS (
SELECT 1 FROM information_schema.partitions p2
WHERE p2.table_schema = DATABASE() AND p2.table_name = tbl AND p2.partition_name = pname
) THEN
SET @ddl = CONCAT('ALTER TABLE ', tbl, ' REORGANIZE PARTITION p_max INTO (',
' PARTITION ', pname, ' VALUES LESS THAN (TO_DAYS(''', next_edge, ''')),',
' PARTITION p_max VALUES LESS THAN MAXVALUE)');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @an = CONCAT('ANALYZE TABLE ', tbl); -- 새 파티션 통계 공백 방지(감사 보완)
PREPARE s FROM @an; EXECUTE s; DEALLOCATE PREPARE s;
END IF;
END;
END LOOP;
CLOSE cur;
END$$
-- ---------------------------------------------------------------------
-- 4. 보존 파티션 DROP (감사 신설: scale-7) - 보존 매트릭스(schema.dbml 서문) 집행
-- DELETE 대신 O(1) DROP. 대상·개월수는 호출부(이벤트)에서 명시.
-- ---------------------------------------------------------------------
CREATE PROCEDURE sp_drop_expired_partitions(IN in_table VARCHAR(64), IN keep_months INT)
BEGIN
DECLARE pname VARCHAR(64);
DECLARE done INT DEFAULT 0;
DECLARE edge VARCHAR(10) DEFAULT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL keep_months MONTH), 'p%Y%m');
DECLARE cur CURSOR FOR
SELECT p.partition_name FROM information_schema.partitions p
WHERE p.table_schema = DATABASE() AND p.table_name = in_table
AND p.partition_name LIKE 'p2%' AND p.partition_name < edge;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur;
drop_loop: LOOP
FETCH cur INTO pname;
IF done = 1 THEN LEAVE drop_loop; END IF;
SET @ddl = CONCAT('ALTER TABLE ', in_table, ' DROP PARTITION ', pname);
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
INSERT INTO audit_logs (actor_type, action, target_type, detail, created_at)
VALUES ('SYSTEM', 'PARTITION_DROP', in_table, pname, NOW());
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
-- 매월 25일 03:00 KST: 파티션 확장 / 04:00: 보존 집행 (outbox 워커가 이중 감시)
CREATE EVENT IF NOT EXISTS ev_extend_partitions
ON SCHEDULE EVERY 1 MONTH STARTS (TIMESTAMP(DATE_FORMAT(CURDATE(), '%Y-%m-25')) + INTERVAL 3 HOUR)
DO CALL sp_extend_month_partitions();
DELIMITER $$
CREATE EVENT IF NOT EXISTS ev_retention_drop
ON SCHEDULE EVERY 1 MONTH STARTS (TIMESTAMP(DATE_FORMAT(CURDATE(), '%Y-%m-25')) + INTERVAL 4 HOUR)
DO BEGIN
CALL sp_drop_expired_partitions('notification_inbox', 2); -- 30일 보존(2개월 여유)
CALL sp_drop_expired_partitions('external_api_logs', 60); -- 5년
CALL sp_drop_expired_partitions('reject_logs', 60);
CALL sp_drop_expired_partitions('app_error_logs', 60);
CALL sp_drop_expired_partitions('login_histories', 60);
CALL sp_drop_expired_partitions('pii_access_logs', 60);
END$$
DELIMITER ;
-- ledger_entries는 보존 DROP 대상 아님(영구 - 보존 매트릭스)
-- ---------------------------------------------------------------------
-- 5. 권한 설계 (1차 방어 - 배포 스크립트에서 실계정·서버 2대 IP로 치환.
-- 'app'@'%' 금지: 'app'@'<API서버1 IP>', 'app'@'<API서버2 IP>'로 한정 - 감사 보완)
-- ---------------------------------------------------------------------
-- INSERT/SELECT 전용(증거·기록): ledger_entries, audit_logs, pii_access_logs,
-- login_histories, reject_logs, external_api_logs, app_error_logs, lot_allocations,
-- policy_agreements, integrity_findings(상태 컬럼만 UPDATE 허용 시 별도)
-- INSERT/SELECT/UPDATE(상태 전이형): transactions(트리거로 컬럼 제한), deposit_notices(동일),
-- point_lots(트리거로 감소만), 나머지 업무 테이블
-- DELETE 허용(보존 매트릭스 명시분만): outbox_jobs(DONE 90일), webhook_deliveries(180일),
-- hourly_summaries(90일)
-- DDL·TRIGGER·EVENT: 마이그레이션 전용 계정만. 파티션 DROP은 이벤트(definer) 경유.
-- ---------------------------------------------------------------------
-- 6. transactions·deposit_notices 연 단위 아카이브 런북 (감사 신설: scale-2)
-- * 비파티션 대형 2종의 5년+ 성장 대응. DBA 절차 - 자동화하지 않는다(위험 작업).
-- 1) 대상 연도 확정(예: 만 5년 경과분). 사전 조건: 해당 기간 UNKNOWN/HOLD 0건 확인.
-- 2) 마이그레이션 계정으로: CREATE TABLE transactions_arch_YYYY LIKE transactions;
-- (트리거 없음 상태로 생성됨을 확인)
-- 3) INSERT INTO transactions_arch_YYYY SELECT ... WHERE created_at < :edge (연 단위 청크)
-- 4) 행수·SUM(amount)·MIN/MAX(id) 대조 검증 기록(audit_logs)
-- 5) 원본 삭제: 방어 트리거 trg_txn_no_delete를 마이그레이션 계정이 일시 DROP →
-- DELETE (id 범위 청크) → 트리거 재생성 → 검증 재실행. 전 과정 audit_logs 기록.
-- 6) 아카이브 테이블은 읽기전용 계정만 SELECT 부여. related_txn_id 교차 참조는
-- 추적 화면에서 아카이브 테이블 UNION 조회로 유지.
-- ---------------------------------------------------------------------