감사 로그
포털의 모든 쓰기 작업은 추가 전용 감사 로그에 기록됩니다. 로그는 "누가 언제 무엇을 무엇에 했는가"의 진실의 원천 — 사고 조사·컴플라이언스 요청 응대 시 가장 먼저 보는 곳입니다.
/admin/audit 페이지는 툴바(actor / target table / action / 시간 범위 필터)와 라이브 데이터 위의 행 표를 노출합니다:

조직 단위 읽기는 super_admin; 팀 단위 읽기는 team_admin.
스키마
각 항목 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
id | UUID | 기본 키. |
created_at | timestamptz | 작업 발생 시각(서버 시계, UTC). |
actor_user_id | UUID | 작업 수행 사용자(시스템 작업은 null). |
team_id | UUID | 해당 시 작업의 팀 범위(조직 단위 쓰기는 null). |
action | text | 동사만(create / update / delete). 테이블은 target_table에 별도 캡처. 예: target_table=projects&action=create로 필터. |
target_table | text | 영향 받은 객체가 속한 테이블(projects, teams, users, vulnerability_findings 등). |
target_id | String(64) | 영향 받은 객체의 식별자. |
request_id | text | 구조화 로그(X-Request-ID)와 상관. |
diff | jsonb | 정제된 before/after diff. PII는 마스킹(mask_pii). |
ip | inet | 출처 IP. |
user_agent | text | 잘린 UA 문자열. |
추가 전용 계약은 두 계층에서 강제됩니다.
-
애플리케이션 — 감사 리스너는 insert 만 발신하며 API가 update / delete 엔드포인트를 노출하지 않습니다.
-
데이터베이스 — 두 개의 트리거(마이그레이션
0012)가 모든 변경 시도에SQLSTATE 23000(integrity_constraint_violation)을 발생시킵니다.audit_logs_immutable_trigger— BEFORE UPDATE OR DELETE, FOR EACH ROW.audit_logs_immutable_truncate— BEFORE TRUNCATE, FOR EACH STATEMENT (PostgreSQL은 row 트리거를 UPDATE / DELETE 에서만 발사 — TRUNCATE는 BEFORE-row를 우회하므로 테이블 wipe 경로에는 별도 statement-level 가드가 필요).
super-admin이 raw
psql로UPDATE audit_logs ...,DELETE FROM audit_logs ...,TRUNCATE TABLE audit_logs를 실행하면ERROR: audit_logs is append-only (TG_OP=…)와 함께 트랜잭션이 abort 됩니다. INSERT는 영향받지 않으므로 리스너 경로는 정상 동작합니다.
이 트리거들은 PR #44가 로드맵으로 문서화했던 defense-in-depth 갭을 닫습니다. 알려진 잔여 우회: 기본 설치에서는 마이그레이션 role과 런타임 앱 role이 동일한 PostgreSQL role (trustedoss) 입니다. 이 role이 함수와 트리거를 소유하므로 DROP TRIGGER / ALTER FUNCTION ... OWNER를 통해 "DROP TRIGGER → mutate → re-CREATE TRIGGER" 우회가 가능합니다. Phase 7 / 8 강화 PR에서 런타임 role과 마이그레이션 role 분리(trustedoss_app은 audit_logs에 DML 만 + trustedoss_owner는 마이그레이션)가 예정되어 있으며, 분리 후에는 런타임 앱에서 우회 불가능. 그 전까지 우회는 관측 가능합니다 — DROP TRIGGER는 DDL 문이라 pg_event_trigger (향후 audit-of-audit 강화)와 두-운영자 retention purge의 운영자 세션 로그가 포착합니다.
하나뿐인 예외와 그 범위
마이그레이션 0080 이후로 트리거가 허용하는 UPDATE는 정확히 하나입니다. 익명화 대상 사용자의 행에서 ip와 user_agent를 비우는 것입니다. 범위는 설계상 좁습니다. 호출자가 테이블 소유자 역할의 구성원이어야 하고, 그 두 열은 NULL 방향으로만 바뀔 수 있으며, diff를 포함한 나머지 모든 열이 그대로여야 하고, 실행하는 함수는 요청 테이블에 그 대상자에 대한 승인된 요청이 없으면 거부합니다.
역할을 분리한 설치에서는 애플리케이션 역할이 소유자 역할의 구성원이 아니므로 실행 중인 포털에서 이 경로에 닿을 수 없습니다. 단일 역할 설치에서는 닿습니다. 그 경우 런타임 역할과 소유자가 같은 역할이어서 구성원 검사가 무조건 참이 되고, 소유자의 암묵적 EXECUTE 권한은 REVOKE로 사라지지 않습니다. 바로 위 문단의 기본 설치 설명이 여기에도 그대로 적용됩니다. 이 예외를 형식이 아니라 실제 경계로 만드는 것은 역할 분리입니다. 사용자 익명화를 참고하십시오.
함수의 승인 검사는 행이 존재하는지를 보는 것이지 두 사람이 동의했음을 증명하는 것이 아닙니다. user_anonymisation_requests에 쓸 수 있는 것은 무엇이든 그런 행을 만들 수 있습니다. 그 뒤를 받치는 것은 제품 안의 2인 승인 절차, 화면에 표시된 두 사람을 확인할 수 있는 운영자, 그리고 데이터베이스에 직접 쓴 요청은 이 로그에 대응하는 항목을 남기지 못한다는 사실입니다.
diff는 의도적으로 예외 밖입니다. 변경의 내용을 고쳐 쓸 수 있을 만큼 넓은 예외는 이 테이블을 증거로 만드는 성질을 없앱니다. 그 결과로 옛 주소가 diff 안에 남을 수 있다는 사실은 사용자 익명화에 적었습니다. 구멍을 넓혀서 조용히 해결하지 않았습니다.
직접 시험해 보기 전에 알아 둘 것이 하나 있습니다. 트리거는 값이 실제로 바뀌었는지로 판정하므로, 이미 가진 값을 다시 쓰는 UPDATE는 아무것도 바꾸지 않아 통과합니다. ip가 이미 NULL인 행에 위조된 스크럽을 겨누면 UPDATE 1이 돌아오는데, 예외가 뚫린 것처럼 읽히지만 그렇지 않습니다.
무엇이 기록되는가
인증된 모든 POST, PATCH, PUT, DELETE가 정확히 하나의 항목을 생성합니다. 읽기 엔드포인트(GET)는 기록하지 않습니다. SBOM 내보내기는 structlog sbom_exported 이벤트를 발신하지만 현재 릴리스에서는 audit_logs 행을 생성하지 않습니다 — 내보내기를 감사 테이블에 통합하는 것은 로드맵 항목입니다.
다음 사용자 가시 작업은 structlog 이벤트를 발신하지만 v0.10.0 에서는
audit_logs 행을 생성하지 않습니다:
- SBOM 내보내기(
sbom_exported) - NOTICE 파일 다운로드(오늘은 structlog 이벤트도 없음; 로드맵 참고)
- API Key 폐기 명시 이벤트(
api_key.revoked; 기저api_keys.updateORM 행은audit_logs에 들어갑니다)
"누가 언제 무엇을 다운로드했나" 컴플라이언스 감사 시
docker-compose logs backend | grep sbom_exported와 Loki / journald
집계기를 확인하세요. 이를 audit_logs 행으로 승격하는 작업은
로드맵 항목입니다.
시스템 작업(Celery)도 기록합니다. 각 행은 동사만 담고 target_table을 별도로 가집니다. 예시:
target_table=scans&action=create(시스템, Webhook이 스캔 트리거)target_table=dt_orphans&action=deletetarget_table=backups&action=createtarget_table=notifications&action=create
Admin UI의 target_table 필터 드롭다운은 apps/backend/schemas/admin_ops.py의 AuditTargetTable 화이트리스트로 제한됩니다. 이 화이트리스트에 없는 테이블 이름(예: dt_orphans, backups, api_keys, notifications, dt_breaker)을 가진 행은 audit_logs에 그대로 기록되지만 raw SQL 로만 조회 가능합니다.
감사 로그 페이지
/admin/audit은 페이징되고 필터 가능한 뷰입니다.
필터
현재 릴리스의 상단 인라인 필터 바:
- 행위자 user ID — UUID 정확 일치.
- 대상 테이블 — enum 단일 선택(
projects,teams,users,vulnerability_findings등). - 동작 — 자유 텍스트 contains(대소문자 무시 — 서버가
ilike를 사용하므로create와CREATE가 같은 행을 매칭합니다). - 날짜 범위 —
from과to(사용자 지정). - 검색 — 자유 텍스트 쿼리(
q). JSON 인코딩된diff컬럼에 대해ilike매칭을 수행합니다.action과target_table은 별도 필터 파라미터(action=,target_table=)이며q는 이 두 컬럼을 매칭하지 않습니다.
필터는 결합됩니다. URL이 갱신되어 동료와 필터된 뷰를 공유 가능. 다중 선택 드롭다운, 프리셋 날짜 범위, 요청 ID 필터, 대상 ID 필터는 로드맵 항목입니다(아래 참고).
테이블
기본 컬럼: created_at, 행위자, 동작, 대상, ip. 행을 클릭하면 전체 diff가 펼쳐집니다.
테이블은 가상화 — 1만 항목도 부드럽게 스크롤.

CSV 내보내기
툴바의 Export CSV는 현재 필터된 결과 집합을 한 번에 최대 10만 행까지 내보냅니다. CSV는 UTF-8 이며 선두에 byte-order mark (EF BB BF)가 붙어 있습니다 — 한국어 / 일본어 / 중국어 로케일의 Excel이 CP949 / SJIS / GB18030으로 폴백하지 않고 UTF-8을 자동 인식하므로, 비ASCII actor 이메일이나 감사 행 diff가 mojibake로 깨지지 않습니다. 이미 UTF-8을 자동 인식하는 도구(LibreOffice, awk, Python csv / utf-8-sig 코덱)는 BOM을 자동으로 제거합니다.
더 큰 윈도는 API로 페이지네이션:
curl -sS \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
"https://trustedoss.example.com/v1/admin/audit?from=2026-01-01&to=2026-01-31&page=1&page_size=200"
응답이 page + page_size로 페이지됩니다. page_size 상한은 200이며(초과
값은 422 반환), 더 넓은 범위는 page를 증가시켜 조회하십시오.
흔한 쿼리
"프로젝트 X를 누가 삭제했나?"
필터: target_table=projects&action=archive&target_id=<project-uuid>. 정확히 한 행이 있습니다. 프로젝트 삭제는 soft delete(행의 archived_at만 채워지고 물리 삭제 없음)라 감사 동사가 archive입니다 — action=delete는 물리 삭제된 행(memberships, teams의 hard delete 등)에만 해당합니다. 보관된 프로젝트를 복원하면 대응하는 action=unarchive 행이 남습니다.
"사용자 Y가 지난주에 무엇을 했나?"
필터: actor=y@acme.com, 날짜 범위 지난 7일. 동작 목록이 활동을 요약합니다.