본문으로 건너뛰기

Silver PG → Trino Catalog (gendpg) 운영 가이드

GenD 의 PG silver 영역 27+ 테이블을 Trino federation gendpg catalog 로 노출해 SQL 에디터에서 bronze (Iceberg) ↔ silver (PG) cross-catalog join 을 가능하게 하는 운영자 가이드입니다.

Spec: docs/superpowers/specs/2026-06-07-silver-pg-trino-catalog-exposure-design.md (Option B1 — Trino PostgreSQL connector federation) 관련 ADR: ADR-011 / ADR-026 (Intel Silver Hybrid Storage)


1. 분석가용 — 사용법

1.1 카탈로그 탐색

GenD UI 의 카탈로그 트리에서 gendpg 노드를 확장하면 public 스키마 아래 silver 테이블(27+) 이 나타납니다. 각 테이블은 Silver badge 가 붙어있어 medallion layer 를 한눈에 식별할 수 있습니다.

gendpg
└── public
├── intel_filings [Silver] (workspace 격리)
├── intel_articles [Silver] (workspace 격리)
├── intel_companies [Silver] (전사 공유)
├── intel_financials [Silver] (전사 공유)
└── ... (27+ intel_*)

Bronze rawiceberg.bronze.intel_*_raw 에 그대로 살아있습니다 — federation 으로 둘 다 동시에 조회할 수 있습니다.

1.2 예시 쿼리

단일 silver 테이블 조회 (행 수준 격리 자동)

SELECT
company_id,
fiscal_year,
fiscal_period,
filing_type
FROM gendpg.public.intel_filings
WHERE fiscal_year = 2025
ORDER BY filing_date DESC
LIMIT 100;

분석가 본인 워크스페이스의 행 + workspace_id IS NULL 전사 공유 seed 만 반환됩니다 (ABAC row filter 자동 주입 — Spec §3.3).

Bronze raw + Silver 정상화 cross-catalog JOIN

-- 신규 신고가 silver 정상화 완료된 비율
SELECT
b.payload['filer_name'] AS filer,
s.fiscal_year,
count(*) AS n_filings
FROM iceberg.bronze.intel_filings_raw b
JOIN gendpg.public.intel_filings s
ON s.external_id = b.payload['rcept_no']
WHERE s.fiscal_year = 2025
GROUP BY 1, 2
ORDER BY 3 DESC
LIMIT 50;

현재 Phase 1 제약 — viewer/analyst 가 JOIN 을 쓰면 ABAC rewriter 가 UnsafeSqlForFilterError 로 fail-closed reject 합니다 (cross-tenant row leak 차단). admin 만 cross-catalog JOIN 을 안전하게 사용할 수 있습니다. Phase 2 (sqlglot AST 도입) 에서 viewer/analyst 도 JOIN 사용 가능 (Spec §3.3).

거부되는 쿼리 (의도된 차단)

다음 쿼리는 access-control denylist 가 차단합니다 (Trino AccessDeniedException):

SELECT * FROM gendpg.public.users; -- 403 (인증 메타)
SELECT * FROM gendpg.public.audit_logs; -- 403 (감사 로그)
SELECT * FROM gendpg.public.api_keys; -- 403 (API 키)
SELECT * FROM gendpg.public.mcp_access_history;-- 403 (MCP 호출 이력)
UPDATE gendpg.public.intel_filings SET ...; -- 403 (read-only)
DELETE FROM gendpg.public.intel_filings; -- 403 (read-only)

2. 운영자용 — 신규 테이블 추가 / denylist 갱신 절차

2.1 신규 silver 테이블 추가 시

GenD PG 에 신규 silver fact 테이블(intel_* 등) 을 추가할 때:

  1. Alembic migration 으로 테이블 생성apps/api/alembic/versions/.
  2. PG 권한 부여gend_trino_readonly 역할에 SELECT GRANT.
    GRANT SELECT ON public.intel_new_table TO gend_trino_readonly;
    -- default privileges 가 적용되어 있으면 추가 GRANT 불필요.
  3. ABAC 대상 등록 (workspace_id 컬럼이 있을 때만)apps/api/src/gend_api/services/trino_workspace_filter.pyWORKSPACE_SCOPED_TABLES set 에 테이블명 추가.

    회귀 가드: tests/services/test_trino_abac_workspace_filter.pytest_workspace_scoped_tables_match_intel_models.

  4. Medallion layer 매핑 — Spec §3.4 의 LAYER_MAP 규칙 (gendpg.public.intel_* → silver) 으로 자동 정상화. 예외 패턴이 필요하면 apps/api/src/gend_api/routers/catalog.py_normalize_layer 로직과 tests/security/test_silver_trino_catalog_integration.py_spec_layer_for 두 곳을 동시에 갱신.

2.2 신규 민감 테이블 추가 시 (denylist 갱신)

GenD PG 에 신규 민감 테이블 (인증 / 감사 / 메타) 을 추가할 때 — 즉 gendpg 카탈로그를 통해 외부에 노출되면 안 되는 테이블 — 3 곳을 모두 갱신 해야 합니다 (회귀 가드가 강제):

(1) PG 권한 REVOKEscripts/sql/2026_06_07_trino_gendpg_readonly_role.sql 패턴을 따라 신규 SQL 파일:

REVOKE ALL ON public.new_sensitive_table FROM gend_trino_readonly;

(2) Trino access-control denylist regex 갱신infra/helm/trino/trino-access-control-cm.yamltables[0].table regex 에 신규 테이블명 추가:

{"catalog": "gendpg", "schema": "public",
"table": "users|sessions|...|new_sensitive_table",
"privileges": []}

(3) 회귀 가드 lint 의 필수 목록 갱신scripts/lint_trino_gendpg_security_policy.pyREQUIRED_DENYLISTED_TABLES tuple 에 신규 테이블명 추가. 동시에 tests/security/test_silver_trino_catalog_integration.py 의 관련 assertion 확장.

빠뜨림 방지: CI 의 lint-trino-gendpg-security-policy 잡이 (1)+(2)+(3) 의 불일치를 즉시 차단합니다. (2) 를 빠뜨리면 lint 가 "denylist regex 가 new_sensitive_table 을 매칭하지 못함" 에러 + exit 1.

2.3 PG 권한 재발급 (회전)

gend_trino_readonly 의 비밀번호가 누출되었거나 정기 회전할 때.

이 절차에는 조용히 실패하는 함정이 3개 있습니다

2026-07-29 실제 회전(#2704) 에서 전부 실측된 것들입니다. 읽고 시작하십시오.

  1. vault kv put 은 경로 전체를 교체합니다. secret/gend/trino 에는 readonly_password 외에 jwt_client_secret 도 들어 있습니다. 한 키만 적어 put 하면 나머지가 지워지고, JWT 를 켜는 시점까지 아무 증상이 없습니다.
  2. PostgreSQL 은 Deployment 입니다statefulset/postgresql 은 NotFound.
  3. 파드 내부에서 검증하면 무조건 통과합니다. pg_hba.confhost all all 127.0.0.1 trust 때문에 루프백 접속은 비밀번호를 보지 않습니다. 실측에서 구 비밀번호와 무작위 문자열 둘 다 접속에 성공했습니다 — 즉 회전 성공/실패를 구분할 수 없습니다. 검증은 반드시 네트워크 경유로.
CTX="--context aks-genos-prod"

# ── 1. 새 비밀번호 생성 ────────────────────────────────────────────
# hex only — '+' 나 '/' 가 섞이면 URL/폼 인코딩 경로에서 값이 변질된다.
NEW=$(openssl rand -hex 27)

# ── 2. Vault 기록 — ★ 기존 키를 함께 실어 전체 교체 사고를 막는다 ──
# (vault kv patch 가 가능하면 그쪽이 더 안전하다)
JWT=$(vault kv get -field=jwt_client_secret secret/gend/trino)
vault kv put secret/gend/trino \
readonly_password="$NEW" \
jwt_client_secret="$JWT"

# 두 키가 모두 살아있는지 즉시 확인 — 여기서 안 보면 나중에 못 찾는다
vault kv get -format=json secret/gend/trino | jq -r '.data.data | keys[]'
# 기대: jwt_client_secret / readonly_password

# ── 3. ESO 동기화 (refresh 1m — 즉시 원하면 force-sync) ────────────
kubectl $CTX -n gend annotate externalsecret trino-gendpg-readonly \
force-sync="$(date +%s)" --overwrite

# K8s Secret 이 새 값으로 바뀔 때까지 대기
until [ "$(kubectl $CTX -n gend get secret trino-gendpg-readonly \
-o jsonpath='{.data.GEND_TRINO_READONLY_PASSWORD}' | base64 -d)" = "$NEW" ]; do
sleep 3
done

# ── 4. PG 변경 — ★ 여기서부터 gendpg 중단 (실측 약 40초) ───────────
# 비밀번호가 argv 에 남지 않도록 stdin 으로 넘긴다.
printf "ALTER ROLE gend_trino_readonly WITH PASSWORD '%s';\n" "$NEW" \
| kubectl $CTX -n gend exec -i deploy/postgresql -c postgresql -- \
psql -U gend -d gend -q

# ── 5. Trino 재기동 (catalog properties 의 env 는 시작 시점에 한 번만 해석) ──
kubectl $CTX -n gend rollout restart deploy/trino-coordinator deploy/trino-worker
kubectl $CTX -n gend rollout status deploy/trino-coordinator --timeout=240s
kubectl $CTX -n gend rollout status deploy/trino-worker --timeout=240s

6. 검증 — 3-케이스. 통제군을 빼면 판별력이 없습니다.

새 비밀번호가 되는 것만 보면 "회전"이 아니라 "추가" 일 수 있고, 통제군이 없으면 검증 경로 자체가 고장 난 것을 못 잡습니다(위 함정 3번).

# 네트워크 경유(hostssl + scram) — PG 파드 내부에서 하면 안 된다.
# 이미 PG 접근이 허용된 파드에서, 비밀번호는 stdin 으로.
kubectl $CTX -n gend exec -i deploy/gend-api -c gend-api -- python3 - <<PY
import asyncio, asyncpg, ssl
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
CASES = [("new", "$NEW", "성공 기대"),
("old", "<이전 비밀번호>", "거부 기대"),
("ctrl-garbage", "zz-control-zz", "거부 기대")]
async def main():
for label, pw, exp in CASES:
try:
c = await asyncpg.connect(user="gend_trino_readonly", password=pw, database="gend",
host="postgresql.gend.svc.cluster.local", port=5432, ssl=ctx, timeout=10)
await c.close(); print(f" {label:14} -> 접속성공 [{exp}]")
except asyncpg.InvalidPasswordError:
print(f" {label:14} -> 인증거부 [{exp}]")
asyncio.run(main())
PY

# E2E — Trino 가 4-홉 chain 을 통과했는지
kubectl $CTX -n gend exec deploy/trino-coordinator -- \
trino --server http://localhost:8080 --user gend-batch \
--execute "SELECT count(*) FROM gendpg.public.intel_companies"

기대 출력:

new -> 접속성공 [성공 기대]
old -> 인증거부 [거부 기대]
ctrl-garbage -> 인증거부 [거부 기대] ← 이게 '접속성공' 이면 검증 경로가 고장난 것

7. 뒷정리 — 임시로 비밀번호를 파일에 적었다면 shred -u 로 파기하고, 검증용 임시 파드를 만들었다면 삭제합니다(컨테이너 로그에 argv 가 남습니다).

Vault → ESO → Secret → pod env → Trino catalog properties 의 4-홉 chain. 자세한 흐름은 infra/external-secrets/migration/trino-gendpg-readonly.yaml 주석 참조.

2.4 dev (Kind) 환경에서 gendpg 활성화

Vault + ESO 가 없는 로컬 dev 에서는 Trino pod 가 CreateContainerConfigError 로 깨질 수 있습니다 (Secret trino-gendpg-readonly 부재). 회피 방법 2 가지:

  • Option A (권장)infra/envs/dev/values-override.yaml 에서 envFrom 빈 리스트로 덮어쓰기 + dev 용 평문 비밀번호로 connection-password override.
  • Option B — 같은 이름의 K8s Secret 을 dev cluster 에 수동 생성.

base values 의 optional: true 설정으로 Secret 부재 시 pod 가 startup 실패하지 않습니다 (Spec §3.2 의 dev 안전 절차).


3. 보안 운영자용 — 3-layer Defense 점검 체크리스트

gendpg catalog 의 보안 모델은 3-layer defense-in-depth 입니다. 어느 한 layer 가 깨져도 나머지 두 layer 가 막아주지만, 정기 감사 시 셋 다 정상인지 확인해야 합니다.

Layer 1 — PG REVOKE (1차 방어선)

책무: 민감 테이블에 대한 PG-level SELECT 차단. Trino 가 어떤 접근제어를 적용하든 PG 가 SQL 단계에서 거부.

점검:

테이블 이름을 하드코딩해서 점검하지 마십시오

과거 이 절은 public.audit_logs 를 예로 들었는데, 그 테이블은 존재하지 않습니다(실제 이름은 audit_reports). 없는 테이블을 대상으로 하면 has_table_privilegerelation does not exist에러를 내고, 이를 "권한 없음" 으로 오독하기 쉽습니다. 아래처럼 커버리지 전체를 세는 방식으로 점검하십시오.

또한 psql -U postgres 는 실패합니다 — prod 의 PG 슈퍼유저는 gend 이고, 워크로드는 StatefulSet 이 아니라 Deployment 입니다.

CTX="--context aks-genos-prod"
PSQL() { kubectl $CTX -n gend exec deploy/postgresql -c postgresql -- psql -U gend -d gend -tAc "$1"; }

# 1. gend_trino_readonly 가 실제로 read-only 인지
PSQL "SELECT rolsuper, rolcreaterole, rolcreatedb, rolbypassrls
FROM pg_roles WHERE rolname='gend_trino_readonly'"
# 기대: f|f|f|f

# 2. 쓰기 권한이 하나도 없는지 (read-only 주장의 실증)
# ★ has_table_privilege 는 이름 대신 oid 로 넘긴다 — 이름 형태로 쓰면
# 플래너가 WHERE 술어를 재정렬해 public 밖 테이블까지 평가하다 에러난다.
PSQL "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='public' AND c.relkind='r'
AND (has_table_privilege('gend_trino_readonly', c.oid,'INSERT')
OR has_table_privilege('gend_trino_readonly', c.oid,'UPDATE')
OR has_table_privilege('gend_trino_readonly', c.oid,'DELETE'))"
# 기대: 0

# 3. REVOKE 커버리지 — 무엇이 실제로 막혀 있는지 전수로 본다
PSQL "SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='public' AND c.relkind='r'
AND NOT has_table_privilege('gend_trino_readonly', c.oid,'SELECT') ORDER BY 1"
2026-07-29 — denylist → allowlist 전환 (#2712)

전환 전 이 카탈로그는 GRANT SELECT ON ALL TABLES + ALTER DEFAULT PRIVILEGES 로 전부 열어두고, 민감해 보이는 이름을 뒤쫓아 REVOKE 하는 방식 이었습니다. 실측 결과 그 방식은 실패했습니다:

public 138개 중 132개 읽기 가능
denylist 정규식 12개 중 6개가 존재하지 않는 테이블을 겨냥
(users / sessions / oauth_.* / api_keys / audit_logs / audit_chain)
정작 노출된 것: data_grants · pii_column_registry · pii_masking_policies …

전환 후 — 기본은 차단이고 intel_* 만 명시 허용합니다.

intel 노출 17
intel 외 노출 0 (전 138개 중)
쓰기 권한 0
신규 자동 부여 해제됨 (pg_default_acl 0건)

⚠️ 알려진 한계 — 이 조치는 접근을 막지만 테이블 이름은 감추지 못합니다. Trino 의 PostgreSQL 커넥터는 목록을 pg_catalog 로 조회하는데 그 경로는 PG 권한 필터를 타지 않습니다. 실측:

PG information_schema.tables (권한 필터됨) → 17
PG pg_catalog.pg_class (권한 무관) → 138
Trino SHOW TABLES → 138 ← 이름은 보인다
Trino SELECT (허용 밖) → permission denied for table …

즉 카탈로그 트리에는 138개가 보이고, 그중 121개는 조회 시 거부됩니다. 데이터는 보호되지만 스키마 이름은 노출됩니다. 목록까지 감추려면 Trino 파일 기반 access-control 이 필요합니다 — #2683 Step 3 에서 다룹니다.

새 테이블을 노출하려면 scripts/sql/2026_06_07_trino_gendpg_readonly_role.sqlallowed_patterns 에 추가하고 재실행하십시오. 추가하지 않으면 자동으로 차단됩니다.

Layer 2 — Trino file-based access-control (2차 방어선)

책무: Trino coordinator 가 SQL plan 단계에서 denylist 매칭 시 즉시 거부 — PG 까지 쿼리가 전달되지 않음.

점검:

"mount 되어 있는지" 는 점검이 아닙니다

과거 이 절의 1번은 rules.json 이 파드에 mount 되어 있는지만 봤습니다. 그런데 파일이 mount 돼 있어도 Trino 가 그것을 쓰고 있다는 뜻이 아닙니다access-control.name=file 프로퍼티가 있어야 로드됩니다.

2026-07-29 prod 실측: rules.json 은 mount 돼 있고(따라서 구 점검은 통과), access-control 프로퍼티는 어디에도 없습니다(따라서 ACL 은 전혀 동작하지 않음). 구 절차대로면 접근제어가 완전히 꺼진 상태를 "정상" 으로 보고했을 것입니다. 이것이 #2683 이 3개월간 발견되지 않은 이유이기도 합니다.

반드시 프로퍼티를 확인하십시오.

CTX="--context aks-genos-prod"

# 1. ★ ACL 이 실제로 활성인지 — 파일 존재가 아니라 프로퍼티로 판정
kubectl $CTX -n gend exec deploy/trino-coordinator -- \
sh -c 'cat /etc/trino/access-control.properties 2>/dev/null \
|| grep -r "^access-control" /etc/trino/ 2>/dev/null \
|| echo "NOT-CONFIGURED"'
# 기대(활성): access-control.name=file
# access-control.config-files=/etc/trino/access-control/rules.json
# NOT-CONFIGURED 가 나오면 아래 2번의 negative test 는 무조건 통과하므로 의미 없음.

# 1-b. 그 다음에야 규칙 내용을 본다
kubectl $CTX -n gend exec deploy/trino-coordinator -- \
cat /etc/trino/access-control/rules.json | jq '.tables[0]'
# 출력: {"catalog": "gendpg", "schema": "public", "table": "users|...|mcp_access_history", "privileges": []}

# 2. analyst JWT 로 SELECT users 시도 (negative test) — admin JWT 도 동일.
trino --server https://trino.gend.svc:8080 \
--user analyst --jwt <ANALYST_JWT> \
--execute "SELECT * FROM gendpg.public.users LIMIT 1"
# Query failed: Access Denied: ...

# 3. CI 회귀 가드 (코드 단)
python3 scripts/lint_trino_gendpg_security_policy.py
# OK: Trino gendpg 보안 정책 lint 통과.

Layer 3 — GenD ABAC row filter (3차 방어선 — workspace 격리)

책무: 표가 노출 허용 상태여도 행 수준 멀티테넌트 (workspace_id) 격리. viewer/analyst 의 SELECT 에 WHERE workspace_id IN (...) OR workspace_id IS NULL 자동 주입.

점검:

# 1. ABAC 모듈이 의도된 테이블 set 을 가지는지
python3 -c "
from gend_api.services.trino_workspace_filter import WORKSPACE_SCOPED_TABLES
print(sorted(WORKSPACE_SCOPED_TABLES))
"
# ['intel_analyst_estimates', 'intel_articles', ..., 'intel_topics']

# 2. viewer 토큰으로 row leak 시도 (E2E)
# 자세한 수동 검증 절차는 GenD prod 운영자 internal 채널 참조.

# 3. 회귀 가드
cd apps/api && .venv/bin/python -m pytest tests/security/test_silver_trino_catalog_integration.py -v
# 11 passed (PR #1903 + #1905 머지 후).

3.1 정기 감사 권장 주기

점검 항목주기책임자
Layer 1 — PG REVOKE 무결성분기DBA
Layer 2 — Trino access-control regexPR 머지 시 자동 (CI lint)보안 운영자
Layer 3 — ABAC rewriter coveragePR 머지 시 자동 (pytest)API 운영자
gend_trino_readonly 비밀번호 회전90 일보안 운영자
신규 민감 테이블 누락 여부분기 + 신규 테이블 추가 시 즉시보안 운영자

3.2 위반 사례 대응

CI lint 가 다음 에러를 출력하면 머지를 차단하고 즉시 spec 참조:

에러원인조치
gendpg connection-user='...' — read-only role 'gend_trino_readonly' 만 허용values.yaml 가 superuser 로 회귀values.yaml 의 connection-user 를 gend_trino_readonly 로 복원
gendpg connection-password 가 ENV substitution 이 아님평문 비밀번호 회귀 (Trino 의 $ENV:VAR 형식 placeholder 미사용)ESO + envFrom + Trino $ENV substitution 패턴 복원
gendpg denylist regex 가 다음 민감 테이블을 매칭하지 못함: [...]누군가 denylist 에서 테이블을 뺐거나 신규 민감 테이블이 등록 안 됨access-control-cm.yaml 의 regex 갱신 또는 lint 의 REQUIRED_DENYLISTED_TABLES 갱신
gendpg denylist 가 group 권한 규칙 보다 뒤에 있음규칙 순서 회귀 — admin 'all' 이 먼저 매칭되어 denylist 무력화denylist 를 group rules 보다 위로 이동

4. 관련 자료

  • Spec (설계): docs/superpowers/specs/2026-06-07-silver-pg-trino-catalog-exposure-design.md
  • ADR-011 / ADR-026 — Intel Silver Hybrid Storage 정책
  • PR #1903 — P1 (Trino catalog + ESO + access-control rules + PG migration)
  • PR #1905 — P3 (ABAC workspace_id row filter)
  • CI 회귀 가드:
    • scripts/lint_trino_gendpg_security_policy.py (manifest-time 정책)
    • apps/api/tests/security/test_silver_trino_catalog_integration.py (end-to-end 계약)
    • apps/api/tests/infra/test_trino_gendpg_catalog.py (P1 24 가드)
    • apps/api/tests/services/test_trino_abac_workspace_filter.py (P3 22 가드)