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 raw 는
iceberg.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_* 등) 을 추가할 때:
- Alembic migration 으로 테이블 생성 —
apps/api/alembic/versions/. - PG 권한 부여 —
gend_trino_readonly역할에 SELECT GRANT.GRANT SELECT ON public.intel_new_table TO gend_trino_readonly;-- default privileges 가 적용되어 있으면 추가 GRANT 불필요. - ABAC 대상 등록 (workspace_id 컬럼이 있을 때만) —
apps/api/src/gend_api/services/trino_workspace_filter.py의WORKSPACE_SCOPED_TABLESset 에 테이블명 추가.회귀 가드:
tests/services/test_trino_abac_workspace_filter.py의test_workspace_scoped_tables_match_intel_models. - 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 권한 REVOKE — scripts/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.yaml 의 tables[0].table regex 에
신규 테이블명 추가:
{"catalog": "gendpg", "schema": "public",
"table": "users|sessions|...|new_sensitive_table",
"privileges": []}
(3) 회귀 가드 lint 의 필수 목록 갱신 —
scripts/lint_trino_gendpg_security_policy.py 의 REQUIRED_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 의 비밀번호가 누출되었거나 정기 회전할 때.
2026-07-29 실제 회전(#2704) 에서 전부 실측된 것들입니다. 읽고 시작하십시오.
vault kv put은 경로 전체를 교체합니다.secret/gend/trino에는readonly_password외에jwt_client_secret도 들어 있습니다. 한 키만 적어put하면 나머지가 지워지고, JWT 를 켜는 시점까지 아무 증상이 없습니다.- PostgreSQL 은 Deployment 입니다 —
statefulset/postgresql은 NotFound. - 파드 내부에서 검증하면 무조건 통과합니다.
pg_hba.conf의host 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_privilege 가 relation 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"
전환 전 이 카탈로그는 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.sql
의 allowed_patterns 에 추가하고 재실행하십시오. 추가하지 않으면 자동으로
차단됩니다.
Layer 2 — Trino file-based access-control (2차 방어선)
책무: Trino coordinator 가 SQL plan 단계에서 denylist 매칭 시 즉시 거부 — PG 까지 쿼리가 전달되지 않음.
점검:
과거 이 절의 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 regex | PR 머지 시 자동 (CI lint) | 보안 운영자 |
| Layer 3 — ABAC rewriter coverage | PR 머지 시 자동 (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 가드)