본문으로 건너뛰기

워크스페이스 관측 (#2719)

Epic #2700 의 결정들(특히 D2 admin break-glass, D1 공유 모델)을 데이터로 판단하기 위한 계측입니다.

왜 세 저장소로 나뉘는가

Prometheus 로는 distinct 사용자를 셀 수 없고, 워크스페이스 slug 는 검증되지 않은 입력이라 라벨로 쓸 수 없습니다(ADR-0033). 그래서 빈도는 메트릭, 누가·무엇을은 감사 로그와 DB 로 답합니다.

메트릭

메트릭라벨의미
gend_workspace_impersonation_totalcaller_membership (none/single/multi)admin 이 멤버십 밖 워크스페이스로 진입한 횟수
gend_workspace_fence_bypass_totalreason (admin/no_slug/no_user)read-fence 를 적용하지 않고 통과한 횟수
gend_sdk_authz_gate_totaldomain, caller_type, modeSDK/MCP 가시성 게이트가 평가된 횟수 (분모)
gend_metric_emit_failures_totalmetric계측 실패 — 0 이 아니면 위 지표를 승격 근거로 쓸 수 없다
workspace_active_decisions_totaldecisionactive workspace 결정 분포 (기존, 중앙 등록으로 이관)

모든 라벨 조합이 기동 시 0 으로 사전 생성됩니다. /metrics 에 시계열이 없으면 그 자체가 계측 장애의 증거입니다.

SDK 게이트 승격 판단 — 이제 비율로 본다

sum(rate(gend_sdk_authz_filtered_total[1w]))
/ sum(rate(gend_sdk_authz_gate_total[1w]))

분모가 없던 동안에는 "걸러낼 게 없었다"와 "게이트가 아예 안 돌았다"가 구분되지 않았습니다. 자세한 승격 절차는 SDK·MCP 데이터 인가.

관측 3종 질의 레시피

(a) 다중 워크스페이스 멤버십 사용자 수

감사 로그(OpenSearch gend-audit-*)로 답합니다 — Prometheus 로는 distinct user 를 셀 수 없습니다.

{
"query": { "range": { "workspace_membership_count": { "gt": 1 } } },
"aggs": { "users": { "cardinality": { "field": "user_id" } } },
"size": 0
}
한계

"실제로 요청을 보낸" 사용자 기준입니다. Keycloak realm 전체 로스터가 필요하면 별도 집계가 필요합니다 — KeycloakAdminService.list_users 가 그룹 path 를 버려 /tenants/<slug> 판별이 안 되므로 그 수정이 선행되어야 합니다.

(b) admin 이 어느 워크스페이스를 보는가

{
"query": { "term": { "event": "workspace.impersonate" } },
"aggs": {
"by_workspace": { "terms": { "field": "tenant_slug" } },
"by_endpoint": { "terms": { "field": "endpoint" } }
},
"size": 0
}

impersonation 이 아닌 일반 admin 요청은 caller_is_admin: true + terms(tenant_slug).

(c) 워크스페이스 × 좌표 (MCP)

SELECT w.slug,
d->>'catalog' AS catalog,
d->>'schema' AS schema,
d->>'table' AS "table",
count(*) AS hits
FROM mcp_access_history h
LEFT JOIN workspaces w ON w.id = h.workspace_id
CROSS JOIN LATERAL json_array_elements(h.datasets) AS d
WHERE h.created_at > now() - interval '7 days'
GROUP BY 1, 2, 3, 4
ORDER BY hits DESC;
datasets 는 JSONB 가 아니라 JSON

jsonb_array_elements 가 아니라 json_array_elements 를 써야 합니다.

매핑 확인 필요

terms/cardinality 집계는 keyword 서브필드를 요구합니다. 배포 후 GET gend-audit-*/_mapping 으로 실제 매핑을 확인해 .keyword 접미사가 필요한지 판정하세요. 신규 필드 3종(workspace_membership_count, caller_is_admin, caller_workspaces)이 처음 색인됩니다.

감사 레코드 — workspace.impersonate

{
"event": "workspace.impersonate",
"user_id": "...", "username": "...", "actor_type": "user",
"tenant_slug": "energy-kogas",
"caller_workspaces": ["finance-invest"],
"workspace_membership_count": 1,
"endpoint": "/api/v1/catalog/catalogs", "method": "GET", "client_ip": "...",
"record_hash": "...", "hmac": "...", "prev_hash": "..."
}
이 레코드는 반드시 서명되어야 합니다

미서명 레코드는 audit_chain_verifyunsignedbroken 으로 집계하고, prod CronJob 은 strict 모드라 매일 exit 1 + Slack 알람이 됩니다. UI 가 모든 요청에 X-Workspace-Slug 를 붙이므로 impersonating admin 은 요청마다 이 경로를 타 — 확정적으로 발생합니다. 회귀 가드는 tests/test_workspace_observability.py::test_impersonation_audit_passes_chain_verification.

배포 후 확인

# (1) 신규 시계열이 값 0 으로 존재하는가 — 사전 생성 동작 증거
kubectl --context aks-genos-prod -n gend exec deploy/gend-api -c gend-api -- \
python3 -c "import urllib.request; m=urllib.request.urlopen('http://localhost:8000/metrics').read().decode(); \
print([l for l in m.split('\n') if 'gend_workspace_' in l or 'gend_sdk_authz_gate' in l][:8])"

# (2) 계측이 살아있는가
# gend_metric_emit_failures_total == 0

# (3) ★ 감사 체인이 깨지지 않았는가 — 배포 다음날 확인
kubectl --context aks-genos-prod -n gend get jobs | grep audit-chain-verify

관련