워크스페이스 관측 (#2719)
Epic #2700 의 결정들(특히 D2 admin break-glass, D1 공유 모델)을 데이터로 판단하기 위한 계측입니다.
Prometheus 로는 distinct 사용자를 셀 수 없고, 워크스페이스 slug 는 검증되지 않은 입력이라 라벨로 쓸 수 없습니다(ADR-0033). 그래서 빈도는 메트릭, 누가·무엇을은 감사 로그와 DB 로 답합니다.
메트릭
| 메트릭 | 라벨 | 의미 |
|---|---|---|
gend_workspace_impersonation_total | caller_membership (none/single/multi) | admin 이 멤버십 밖 워크스페이스로 진입한 횟수 |
gend_workspace_fence_bypass_total | reason (admin/no_slug/no_user) | read-fence 를 적용하지 않고 통과한 횟수 |
gend_sdk_authz_gate_total | domain, caller_type, mode | SDK/MCP 가시성 게이트가 평가된 횟수 (분모) |
gend_metric_emit_failures_total | metric | 계측 실패 — 0 이 아니면 위 지표를 승격 근거로 쓸 수 없다 |
workspace_active_decisions_total | decision | active 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 가 아니라 JSONjsonb_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_verify 가 unsigned → broken 으로 집계하고,
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