감사 로그, 히스토리, 시점 조회의 보안적 의미
⚠️ 이 문서는 감사 커밋
7d60c82시점의 스냅샷이다. 이후 Critical 5건과 High 8건(4건 수정 · 4건 부분)이 반영되었으므로, 여기 서술된 결함 중 일부는 현재 코드에 더 이상 존재하지 않는다. 현재 상태는 10_fixed.md 를 볼 것.
이 문서가 답하는 질문
- 무엇이 감사 로그에 기록되고, 무엇이 기록되지 않는가?
- 실패한 질의는 왜 감사 로그에 남지 않는가?
- 히스토리 캡처는 RLS·기밀성에 어떤 영향을 주는가?
- 시점 조회(
og_as_of)를 규정 준수 근거로 쓸 수 있는가?
1. 감사 로그 — 무엇이 기록되는가 (사실)
스키마:
-- engine/sql/bootstrap.sql:377-390
CREATE TABLE og_data.og_audit (
audit_id bigserial PRIMARY KEY,
principal text NOT NULL DEFAULT session_user,
at timestamptz NOT NULL DEFAULT now(),
query text,
lang text, -- 'cypher' | 'sparql' | 'sql'
rows_out int8,
duration_ms double precision,
error_code text
);
CREATE INDEX og_audit_at_idx ON og_data.og_audit (at DESC);
기록 지점:
// engine/src/cypher/mod.rs:122-135
fn audit(graph: &str, query: &str, rows: i64, started: std::time::Instant, err: Option<&str>) {
let ms = started.elapsed().as_secs_f64() * 1000.0;
Spi::run_with_args(
"INSERT INTO og_data.og_audit (query, lang, rows_out, duration_ms, error_code)
VALUES ($1, 'cypher', $2, $3, $4)",
&[
format!("[{graph}] {query}").into(),
rows.into(),
ms.into(),
err.map(|e| e.chars().take(200).collect::<String>()).into(),
],
)
.ok();
}
호출부는 두 군데다:
// engine/src/cypher/mod.rs:93-108
let ast = match parser::parse(query) {
Ok(a) => a,
Err(e) => {
audit(graph, query, 0, started, Some(&e));
error!("cypher parse error: {e}") // ← 트랜잭션 중단
}
};
let rows = if is_write(&ast) { run_write(graph, &ast, ¶ms.0) }
else { run_read(graph, query, ¶ms.0) };
audit(graph, query, rows.len() as i64, started, None);
TypeQL 쪽도 같은 형태다(engine/src/typeql/mod.rs:115-128, lang 값만 `'typeql').
2. 확인된 결함
A-1. 실패한 질의는 절대 기록되지 않는다 (CWE-778)
engine/src/cypher/mod.rs:96-97 의 순서를 보라.
audit(...) ← INSERT 를 호출자의 트랜잭션 안에서 수행
error!(...) ← ereport(ERROR) → 트랜잭션 중단 → 방금 INSERT 한 행이 롤백
파서 오류 경로에서 감사 행은 항상 롤백된다. 그리고 컴파일·실행 단계의 오류(run_read → exec_json → error!, engine/src/cypher/mod.rs:149; compile_cached 실패 → :140)는 아예 audit()을 거치지 않는다.
결과: og_audit에는 성공한 질의만 남는다. error_code 컬럼은 스키마에 존재하지만 실질적으로 항상 NULL이다. 침해 조사에서 가장 필요한 신호 — 실패한 접근 시도 — 가 통째로 없다.
A-2. 감사 실패가 조용히 무시된다
Spi::run_with_args(...).ok() — engine/src/cypher/mod.rs:134. og_data.og_audit에 INSERT 권한이 없으면 감사만 사라지고 질의는 정상 수행된다. 즉 감사를 끄는 방법이 REVOKE INSERT 한 줄이며, 그 사실이 아무 곳에도 드러나지 않는다 (CWE-778).
A-3. 질의 원문이 리터럴째 저장된다 (CWE-532)
format!("[{graph}] {query}") — 파라미터화되지 않은 Cypher 리터럴 (예: MATCH (u:User {ssn: '123-45-6789'}) …)이 그대로 들어간다. params(jsonb)는 저장되지 않으므로, 파라미터를 쓴 애플리케이션은 감사가 빈약하고, 리터럴을 쓴 애플리케이션은 감사에 PII가 쌓인다.
A-4. og_audit 에 RLS도 보존 기간도 없다
| 사실 | 근거 |
|---|---|
| RLS 정책이 걸리지 않는다 | og_enable_rls(engine/src/interop/mod.rs:22-23)는 타입 저장 테이블만 대상으로 한다 |
| 정리(retention) 코드가 없다 | 저장소 전체에서 og_audit 에 대한 DELETE가 검색되지 않는다 |
| 백업에 포함된다 | engine/sql/bootstrap.sql:431 pg_extension_config_dump('og_data.og_audit', '') |
| 무인증으로 노출된다 | portal/server/index.js:283-293 GET /api/audit |
즉 og_data.og_audit 에 SELECT 권한이 있는 모든 역할이 다른 주체의 질의 원문 전체를 읽는다. 다중 테넌트에서는 이 자체가 데이터 유출이다.
A-5. principal 은 session_user 다
bootstrap.sql:382 principal text NOT NULL DEFAULT session_user. PostgREST처럼 SET LOCAL ROLE로 역할을 바꾸는 아키텍처에서는 session_user가 연결 역할(예: authenticator)이고 current_user가 실제 주체다. 따라서 이 컬럼은 그 배포에서 잘못된 주체를 기록한다. Bolt 게이트웨이는 세션마다 별도 접속을 만드므로(bolt/src/session.rs:173-185) Bolt 경로에서는 올바르다.
A-6. 직접 SQL은 감사되지 않는다
og_audit는 og_cypher/og_typeql 함수 안에서만 기록된다. Studio의 POST /api/sql(portal/server/index.js:299)이나 psql로 직접 실행한 SELECT * FROM og_data.n_5 는 아무 흔적도 남기지 않는다. lang 컬럼에 'sql' 값이 예정되어 있으나(bootstrap.sql:385 주석) 그 값을 쓰는 코드는 없다.
3. 히스토리 캡처 (spec 008 FR-018..FR-023)
3.1 어떻게 켜지는가
// engine/src/agent/mod.rs:447-468
#[pg_extern]
fn og_enable_history(graph: &str, type_name: &str) {
let gid = types::graph_id(graph);
let tid = types::type_id(gid, type_name);
for sub in crate::catalog::labeling::og_subtypes(tid) {
let Some(table) = types::storage_table(sub) else { continue };
let trig = format!("og_hist_{sub}");
Spi::run(&format!(
"CREATE OR REPLACE TRIGGER {trig}
AFTER INSERT OR UPDATE OR DELETE ON {table}
FOR EACH ROW EXECUTE FUNCTION og_capture_history()"
))
트리거 본문:
-- engine/sql/access.sql:274-295
CREATE FUNCTION og_capture_history() RETURNS trigger LANGUAGE plpgsql AS $$
…
IF TG_OP = 'DELETE' THEN
eid := OLD.id; op := 'd'; doc := to_jsonb(OLD);
ELSE
eid := NEW.id;
op := CASE TG_OP WHEN 'INSERT' THEN 'i'::"char" ELSE 'u'::"char" END;
doc := to_jsonb(NEW);
END IF;
UPDATE og_data.og_history SET valid_to = now()
WHERE entity_id = eid AND valid_to IS NULL;
INSERT INTO og_data.og_history (entity_id, is_edge, op, payload)
VALUES (eid, doc ? 'src', op, doc);
3.2 보안적 의미
| 사실 | 근거 | 함의 |
|---|---|---|
to_jsonb(NEW) 는 행 전체를 복사한다 |
access.sql:281, 285 |
RLS로 가리려던 컬럼이 그대로 복제된다 |
og_data.og_history 에 RLS가 없다 |
bootstrap.sql:310-322, og_enable_rls는 이 테이블을 건드리지 않는다 |
정책 우회 사본이 생긴다 |
og_history(id) 가 그 테이블을 직접 읽는다 |
engine/src/agent/mod.rs:471-499 |
임의 id로 과거 값 조회 |
og_as_of(id, ts) 도 마찬가지 |
engine/src/agent/mod.rs:502-526 |
동상 |
| 식별자가 예측 가능하다 | alloc_id(engine/src/storage/mod.rs:24-34)가 타입별 1부터 증가, og_make_id가 결정적 |
열거 공격 가능 |
| 트리거 함수 이름이 스키마 한정이 아니다 | agent/mod.rs:458 EXECUTE FUNCTION og_capture_history() |
search_path 하이재킹 표면 (04 §8) |
삭제(op='d')도 페이로드를 남긴다 |
access.sql:281 |
삭제 요청(GDPR 등)이 이 테이블에서 완결되지 않는다 |
| 정리 함수가 없다 | 저장소에 og_history 를 지우는 코드 없음 |
무기한 증가 |
| 백업에 포함된다 | bootstrap.sql:429 |
결론: 히스토리를 켜는 것은 "RLS로 가린 값을 RLS 없는 테이블에 복제하는 것"과 같다. RLS 격리를 쓰는 배포에서 og_enable_history는 격리를 무효화한다.
3.3 히스토리로 감시할 수 없는 것
| 사건 | 히스토리에 남는가 | 이유 |
|---|---|---|
| 노드/엣지 프로퍼티 변경 | 예 (해당 타입에 켜 두었을 때) | 트리거 |
| 인접 구조 변경 | 아니오 | og_data.og_adj 에 트리거를 붙이지 않는다 |
| 노드/엣지 레지스트리 변경 | 아니오 | og_data.og_node / og_edge 에 붙이지 않는다 |
| 스키마 변경 (타입/프로퍼티 추가·삭제) | 부분 | og_catalog.schema_version 에 설명 문자열만 (labeling.rs:176-181) |
| 라벨 이름 변경 | 부분 | bump_schema_version(gid, "rename {old} -> {new}") (cypher/mod.rs:572) |
| 권한/RLS 정책 변경 | 아니오 | 어디에도 기록되지 않는다 |
| 읽기(조회) | 아니오 | og_audit 에만, 그것도 성공한 것만 |
즉 엣지 생성·삭제는 히스토리에 남지 않는다 — create_edge_inner는 타입 테이블에 INSERT 하므로 그 타입에 히스토리가 켜져 있으면 남지만 (engine/src/storage/mod.rs:435-442), og_adj 갱신(:445-446)과 레지스트리(:429-433)는 남지 않는다.
4. 시점 조회 og_as_of 의 한계 (사실)
// engine/src/agent/mod.rs:502-526
#[pg_extern(stable)]
fn og_as_of(id: i64, at: pgrx::datum::TimestampWithTimeZone) -> JsonB {
let tracked = crate::spiu::one::<bool>(
"SELECT true FROM og_data.og_history WHERE entity_id = $1 LIMIT 1", …)…;
if !tracked {
error!(
"no history is retained for entity {id}. enable it with \
og_enable_history(graph, type) — returning the current value would be a lie"
);
}
crate::spiu::one::<JsonB>(
"SELECT payload FROM og_data.og_history
WHERE entity_id = $1 AND recorded_at <= $2
ORDER BY recorded_at DESC LIMIT 1", …)
| 확인된 방어 | 근거 |
|---|---|
| 히스토리가 없으면 현재 값을 반환하지 않고 명시적으로 실패한다 | agent/mod.rs:511-516 — "returning the current value instead would be a lie" |
| 확인된 한계 | 근거 | 규정 준수상 함의 |
|---|---|---|
recorded_at은 트리거 실행 시각(now() = 트랜잭션 시작 시각)이다 |
bootstrap.sql:317 |
커밋 순서와 다를 수 있어 엄밀한 시점 일관성이 아니다 |
| 히스토리를 켜기 전의 상태는 존재하지 않는다 | 트리거는 부착 시점부터 | 소급 감사 불가 |
| 조회 자체가 감사되지 않는다 | og_as_of는 audit()을 호출하지 않는다 |
"누가 과거 값을 열람했는가"가 남지 않는다 |
페이로드가 물리 컬럼명 기준이다 (p_*) |
to_jsonb(NEW) (access.sql:285) |
프로퍼티 이름과 다르며, column_name 충돌(04 §5)이 있으면 모호해진다 |
| 페이로드가 변경 불가 보장(WORM/append-only)이 아니다 | 평범한 힙 테이블, UPDATE/DELETE 가능 |
감사 증적(tamper-evidence)이 없다 |
| 삭제된 엔티티의 히스토리는 남는다 | access.sql:280-282 |
삭제 요청 이행 시 별도 처리 필요 |
판정: og_history/og_as_of는 디버깅·데이터 계보 추적용으로는 유용하나, 규정 준수의 감사 증적으로 쓸 수 없다. 무결성 보호도, 조회 감사도, 삭제 정책도 없다.
5. 요약 다이어그램
flowchart TB
Q["og_cypher(query)"] -->|"파싱 성공 + 실행 성공"| A[("og_data.og_audit<br/>RLS 없음 · 보존정책 없음")]
Q -->|"파싱 실패"| RB["audit INSERT → error!()<br/>→ 트랜잭션 롤백"]
RB -.->|"기록되지 않음"| X(("∅"))
Q -->|"컴파일/실행 실패"| X
W["타입 테이블 쓰기"] -->|"트리거 (켠 경우)"| H[("og_data.og_history<br/>to_jsonb(NEW) 전체<br/>RLS 없음")]
T["og_data.og_adj / og_node / og_edge"] -.->|"트리거 없음"| X
A --> UI["GET /api/audit<br/>무인증 노출"]
H --> AS["og_history(id) / og_as_of(id, ts)<br/>소유권 검사 없음 · id 예측 가능"]
classDef bad fill:#3a1414,stroke:#c04040,color:#f0d0d0
class X,UI,AS bad
Forbidden (금지)
og_data.og_audit를 규정 준수 감사 증적으로 제시하지 말 것. 실패한 질의가 없고(A-1), 직접 SQL이 없고(A-6), 무결성 보호가 없다.og_history/og_as_of를 법적 증거나 개인정보 삭제 이행의 근거로 쓰지 말 것. 삭제된 값이 페이로드에 남고 변조 방지가 없다(§4).- RLS로 격리하는 배포에서
og_enable_history를 호출하지 말 것. 정책 밖 테이블에 같은 값을 복제한다(§3.2). og_data.og_history/og_data.og_audit에SELECT권한을 애플리케이션 역할에 부여하지 말 것.GET /api/audit를 노출한 채 Studio를 실행하지 말 것 (06_network_exposure.md§2.3).- 파라미터화 대신 Cypher 리터럴에 민감 값을 넣지 말 것. 감사 테이블에 평문으로 남는다(A-3).
Required (필수)
- 감사가 반드시 필요하면 PostgreSQL 수준의 로깅(
pgaudit또는log_statement = 'all')을 병행할 것.og_audit만으로는 부족하다. og_data.og_audit/og_data.og_history에 보존 기간 정책(주기적DELETE)을 운영자가 직접 마련할 것 — 코드에 없다.- 히스토리를 켠 타입의 목록을 별도 관리할 것.
og_catalog.setting의history.<graph>.<type>키가 표식이다 (engine/src/agent/mod.rs:462-467). audit()를 수정하는 PR에서는 "실패 경로가 기록되는가"를 회귀 테스트로 검증할 것 (현재는 기록되지 않는다).- 새 쓰기 경로(
storage/,typeql/write.rs)를 추가하면 §3.3 표의 "히스토리에 남는가" 열을 갱신할 것.