본문 바로가기
WIKI 기술 지식 베이스

보안 임베디드 대시보드

원문 보기 위키 갱신

보안 임베디드 대시보드 (Secure Embedded Dashboards)

보안 임베디드 대시보드(Secure Embedded Dashboards)는 대시보드를 공개적으로 접근 가능하게 만들지 않고도 인증된 외부 웹사이트에 Datadog 대시보드를 임베드할 수 있게 해 주는 기능이에요. 일반 임베디드 대시보드와 달리, 보안 임베디드 대시보드는 페이지를 로드할 때마다 백엔드에서 생성한 토큰으로 인증을 수행해요.

이 방식에서는 자격 증명(credential)이 브라우저로 절대 전송되지 않아요. 대신 애플리케이션 서버가 자격 증명을 보관하고 세션마다 고유한 iFrame URL을 생성해요. URL이 의도된 세션 또는 애플리케이션 밖으로 복사되어 재사용되면 더 이상 유효하지 않게 되어, 대시보드가 인증된 환경 내에서만 접근 가능하게 유지돼요.

출처: 문서

본문

개요

보안 임베디드 대시보드는 대시보드를 공개적으로 접근 가능하게 만들지 않고도 외부의 인증된 웹사이트에 Datadog 대시보드를 임베드할 수 있게 해 주는 기능이에요. 표준 임베디드 대시보드와 달리, 보안 임베디드 대시보드는 페이지를 로드할 때마다 백엔드에서 생성한 토큰으로 인증해요. 애플리케이션 서버가 자격 증명을 보관하고 세션마다 고유한 iFrame URL을 생성하므로, 자격 증명이 브라우저로 전송되는 일은 없어요.

URL이 의도된 세션 또는 애플리케이션 밖으로 복사되거나 재사용되면 더 이상 유효하지 않아요. 이를 통해 대시보드가 인증된 환경 내에서만 접근 가능하도록 보장할 수 있어요.

보안 임베디드 대시보드는 다음과 같은 경우에 사용해요:

  • 공개 접근이 허용되지 않는 고객 대상 또는 인증된 애플리케이션에 대시보드를 임베드해야 할 때
  • Safari처럼 서드파티 쿠키를 차단하는 브라우저를 지원해야 할 때

사전 요구 사항

보안 임베드 만들기

  1. 임베드하려는 대시보드에서 오른쪽 위의 Share를 클릭해요.
  2. Share dashboard를 선택해요.
  3. Embed 옵션을 선택해요.
  4. Embed Type에서 Secure를 토글해요.
  5. 이름, 기본 시간 범위, 테마 옵션을 구성해요.
  6. Share Dashboard를 클릭해요.

보안 임베드를 만든 후 Datadog는 base URL(기본 URL)과 credential(자격 증명)을 표시해요. 자격 증명은 한 번만 표시되므로 안전하게 복사해서 백엔드에 저장해 둬요. 자격 증명은 API 키처럼 취급해야 해요. 자격 증명을 가진 사람은 누구나 이 대시보드에 대한 유효한 임베드 URL을 생성할 수 있기 때문이에요.

기본 URL은 공유 모달에서 언제든지 확인할 수 있지만, 자격 증명은 최초 생성 이후에는 마스킹되어 보이지 않아요.

참고: 기존 공유 대시보드의 공유 유형을 Secure Embed로 바꾸거나 Secure Embed에서 다른 유형으로 변경할 수는 없어요. 공유 유형을 바꾸려면 현재 공유 대시보드를 삭제하고 새로 만들어야 해요.

iFrame URL 생성하기

브라우저 사용자가 임베디드 대시보드를 포함한 페이지를 로드할 때마다 백엔드에서 고유하고 수명이 짧은 iFrame URL을 생성해야 해요. 이 URL에는 자격 증명으로 서명된 로그인 토큰이 포함되며, 서명에는 HMAC-SHA256이 사용돼요.

프론트엔드 코드에 자격 증명을 절대 노출하지 마세요. 자격 증명을 획득한 사람은 누구나 대시보드에 대한 유효한 임베드 URL을 생성할 수 있어요.

URL 형식

<BASE_URL>?token=<LOGIN_TOKEN>&nonce=<NONCE>&ts=<TIMESTAMP>
파라미터 설명
BASE_URL 보안 임베드 구성의 기본 URL.
LOGIN_TOKEN `NONCE
NONCE 요청마다 고유하게 무작위로 생성된 값.
TIMESTAMP 초 단위의 현재 UNIX 타임스탬프.

Datadog는 매 요청마다 로그인 토큰을 검증해요. 토큰은 타임스탬프로부터 30분 동안 유효하며 한 번만 사용할 수 있어요. 검증에 성공하면 브라우저는 30일 동안 임베디드 대시보드에 접근할 수 있어요.

백엔드 코드 예시

다음 예시는 백엔드 서버에서 보안 임베드 URL을 생성하는 방법을 보여줘요. 보안 자격 증명 저장소의 자격 증명과 보안 임베드 구성의 기본 URL을 제공하면 돼요.

Python:

import hmac
import hashlib
import time
import secrets
from urllib.parse import urlencode

def generate_secure_embed_url(
    credential: str,
    base_url: str,
) -> str:
    nonce = secrets.token_hex(16)
    timestamp = int(time.time())

    # Message used to derive the token
    msg = f"{nonce}|{timestamp}".encode("utf-8")

    login_token = hmac.new(
        key=credential.encode("utf-8"),
        msg=msg,
        digestmod=hashlib.sha256,
    ).hexdigest()

    query = urlencode({
        "token": login_token,
        "nonce": nonce,
        "ts": str(timestamp),
    })

    return f"{base_url}?{query}"

JavaScript:

const crypto = require('crypto');
const querystring = require('querystring');

function generateSecureEmbedUrl(credential, baseUrl) {
  const nonce = crypto.randomBytes(16).toString('hex');
  const timestamp = Math.floor(Date.now() / 1000);
  const msg = `${nonce}|${timestamp}`;
  const loginToken = crypto
    .createHmac('sha256', credential)
    .update(msg)
    .digest('hex');
  const query = querystring.stringify({
    token: loginToken,
    nonce: nonce,
    ts: String(timestamp),
  });
  return `${baseUrl}?${query}`;
}

Ruby:

require 'openssl'
require 'securerandom'
require 'uri'

def generate_secure_embed_url(credential, base_url)
  nonce = SecureRandom.hex(16)
  timestamp = Time.now.to_i
  msg = "#{nonce}|#{timestamp}"
  login_token = OpenSSL::HMAC.hexdigest('SHA256', credential, msg)
  query = URI.encode_www_form(token: login_token, nonce: nonce, ts: timestamp.to_s)
  "#{base_url}?#{query}"
end

Go:

package main

import (
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "net/url"
    "strconv"
    "time"
)

func generateSecureEmbedURL(credential, baseURL string) string {
    nonceBytes := make([]byte, 16)
    rand.Read(nonceBytes)
    nonce := hex.EncodeToString(nonceBytes)
    timestamp := time.Now().Unix()

    msg := fmt.Sprintf("%s|%d", nonce, timestamp)
    mac := hmac.New(sha256.New, []byte(credential))
    mac.Write([]byte(msg))
    loginToken := hex.EncodeToString(mac.Sum(nil))

    params := url.Values{}
    params.Set("token", loginToken)
    params.Set("nonce", nonce)
    params.Set("ts", strconv.FormatInt(timestamp, 10))

    return fmt.Sprintf("%s?%s", baseURL, params.Encode())
}

iFrame 임베드하기

iFrame을 통합하는 방법은 애플리케이션이 HTML을 클라이언트 측에서 렌더링하는지, 서버 측에서 렌더링하는지에 따라 달라져요.

클라이언트 측 렌더링: 백엔드가 보안 URL을 반환하는 API 엔드포인트를 노출해요. 프론트엔드가 URL을 가져와 iFrame을 동적으로 렌더링해요.

백엔드 API 엔드포인트(Python/Flask 예시):

# Optional: Replace '*' with your application's origin to restrict cross-origin requests.
@app.get("/api/embed-url")
@cross_origin(origins="*", methods=["GET", "OPTIONS"], allow_headers=["Content-Type"])
def embed_url():
    credential = get_credential_from_secure_store()
    base_url = get_base_url_from_secure_store()
    iframe_url = generate_secure_embed_url(credential, base_url)
    return jsonify({"iframeUrl": iframe_url})

프론트엔드(vanilla JavaScript):

<div id="container">Loading...</div>
<script>
  async function renderEmbed() {
    const container = document.getElementById("container");
    try {
      const res = await fetch("/api/embed-url");
      if (!res.ok) throw new Error(`Failed: ${res.status}`);
      const { iframeUrl } = await res.json();

      const iframe = document.createElement("iframe");
      iframe.src = iframeUrl;
      iframe.style.width = "100%";
      iframe.style.height = "85vh";
      iframe.style.border = "0";
      iframe.loading = "lazy";
      iframe.referrerPolicy = "no-referrer";
      iframe.allow = "fullscreen";
      iframe.title = "Secure Embedded Dashboard";

      container.innerHTML = "";
      container.appendChild(iframe);
    } catch (e) {
      container.textContent = `Error loading embed: ${e.message}`;
    }
  }

  renderEmbed();
</script>

서버 측 렌더링: 서버에서 보안 URL을 생성해 HTML 응답에 직접 렌더링해요.

백엔드(Python/Flask 예시):

@app.get("/dashboard")
def dashboard():
    credential = get_credential_from_secure_store()
    base_url = get_base_url_from_secure_store()
    iframe_url = generate_secure_embed_url(credential, base_url)
    return render_template("dashboard.html", iframe_url=iframe_url)

HTML 템플릿:

<iframe
  src="{{ iframe_url }}"
  style="width: 100%; height: 85vh; border: 0;"
  referrerpolicy="no-referrer"
  loading="lazy"
  allow="fullscreen"
></iframe>

장시간 표시 (Long-running displays)

사무실 TV 디스플레이, 키오스크, NOC 벽과 같이 사용자 상호작용 없이 오랜 시간 열려 있는 대시보드의 경우, 임베딩 페이지는 정기적으로 iFrame의 URL을 새로 서명된 URL로 교체해야 해요. 원래 서명된 URL은 일회용이라 초기 페이지 로드 이후에는 재사용할 수 없어요.

임베디드 대시보드는 오랜 무활동 기간 후 스스로 새로고침되어 상태를 최신으로 유지해요. 사용자가 활발히 입력하면 내부 타이머가 재설정돼요. 반면 사람이 없는 디스플레이는 정기적인 일정으로 새로운 서명 URL을 공급해야 해요.

서명된 URL을 30분마다 재생성하고 iFrame src를 업데이트해요.

<iframe id="dashboard" src=""></iframe>
<script>
  const iframe = document.getElementById("dashboard");

  async function refreshEmbed() {
    const res = await fetch("/api/embed-url");
    const { iframeUrl } = await res.json();
    iframe.src = iframeUrl;
  }

  refreshEmbed();
  setInterval(refreshEmbed, 30 * 60 * 1000);
</script>

멀티 테넌시 (Multi-tenancy)

단일 원본 대시보드에서 여러 테넌트를 서비스하려면 테넌트당 하나의 보안 임베드를 만들어요. selectable_template_vars를 사용해 각 테넌트의 기본 템플릿 변수 값을 자체 리소스로 한정할 수 있어요. 각 테넌트는 고유한 자격 증명과 기본 URL을 가지며, 백엔드는 이를 저장하고 iFrame URL을 생성할 때 조회해요.

Dashboard
  └── Secure Embed 1: Tenant A  →  default_values scoped to Tenant A
  └── Secure Embed 2: Tenant B  →  default_values scoped to Tenant B
  └── Secure Embed 3: Tenant C  →  default_values scoped to Tenant C

테넌트 임베드 관리

다음 예시는 Secure Embed API를 사용해 테넌트별로 임베드를 생성, 업데이트, 삭제하는 방법을 보여줘요.

이 코드를 사용하기 전에 환경에 맞게 다음 헬퍼 함수들을 구현해 주세요:

  • get_template_var_value_for_tenant(tenant_id): 테넌트의 템플릿 변수 값을 반환
  • is_new_tenant(tenant_id), is_existing_tenant(tenant_id), is_offboarding_tenant(tenant_id): 테넌트 수명 주기 확인
  • save_tenant_credentials(tenant_id, token, base_url, credential): 테넌트의 공유 토큰, 기본 URL, 자격 증명을 시크릿 저장소에 저장
  • get_secure_embed_token_for_tenant(tenant_id): 시크릿 저장소에서 테넌트의 공유 토큰 조회
  • get_base_url_for_tenant(tenant_id): 시크릿 저장소에서 테넌트의 기본 URL 조회
  • get_credential_for_tenant(tenant_id): 시크릿 저장소에서 테넌트의 자격 증명 조회
  • get_tenant_id_for_user(user_id): 인증된 사용자를 테넌트 ID에 매핑
  • delete_tenant_credentials(tenant_id): 시크릿 저장소에서 테넌트 자격 증명 제거
import requests

DD_API_URL = "https://api.datadoghq.com"
DASHBOARD_ID = "abc-def-ghi"
TEMPLATE_VAR_NAME = "<TEMPLATE_VAR_NAME>"      # Template variable name as defined on the dashboard
TEMPLATE_VAR_PREFIX = "<TEMPLATE_VAR_PREFIX>"  # Template variable prefix as defined on the dashboard
HEADERS = {
    "Content-Type": "application/vnd.api+json",
    "DD-API-KEY": DD_API_KEY,
    "DD-APPLICATION-KEY": DD_APP_KEY,
}


def build_selectable_template_vars(tenant_id: str) -> list[dict]:
    return [
        {
            "name": TEMPLATE_VAR_NAME,
            "prefix": TEMPLATE_VAR_PREFIX,
            "default_values": get_template_var_value_for_tenant(tenant_id),
        },
    ]


def onboard_tenant(tenant_id: str) -> dict:
    resp = requests.post(
        f"{DD_API_URL}/api/v2/dashboard/{DASHBOARD_ID}/shared/secure-embed",
        headers=HEADERS,
        json={
            "data": {
                "type": "secure_embed_request",
                "attributes": {
                    "status": "active",
                    "title": f"Dashboard - Tenant {tenant_id}",
                    "global_time_selectable": False,
                    "selectable_template_vars": build_selectable_template_vars(tenant_id),
                    "viewing_preferences": {"high_density": False, "theme": "system"},
                    "global_time": {"live_span": "1h"},
                },
            }
        },
    )
    resp.raise_for_status()
    data = resp.json()["data"]["attributes"]

    # The credential is only returned on creation. Store it in a secret store.
    return {
        "token": data["token"],
        "base_url": data["url"],
        "credential": data["credential"],
    }


def update_tenant(tenant_id: str, share_token: str):
    resp = requests.patch(
        f"{DD_API_URL}/api/v2/dashboard/{DASHBOARD_ID}/shared/secure-embed/{share_token}",
        headers=HEADERS,
        json={
            "data": {
                "type": "secure_embed_update_request",
                "attributes": {
                    "selectable_template_vars": build_selectable_template_vars(tenant_id),
                },
            }
        },
    )
    resp.raise_for_status()


def offboard_tenant(share_token: str):
    resp = requests.delete(
        f"{DD_API_URL}/api/v2/dashboard/{DASHBOARD_ID}/shared/secure-embed/{share_token}",
        headers=HEADERS,
    )
    resp.raise_for_status()


def manage_tenant(tenant_id: str):
    if is_new_tenant(tenant_id):
        result = onboard_tenant(tenant_id)
        save_tenant_credentials(
            tenant_id, result["token"], result["base_url"], result["credential"]
        )

    elif is_existing_tenant(tenant_id):
        token = get_secure_embed_token_for_tenant(tenant_id)
        update_tenant(tenant_id, token)

    elif is_offboarding_tenant(tenant_id):
        token = get_secure_embed_token_for_tenant(tenant_id)
        offboard_tenant(token)
        delete_tenant_credentials(tenant_id)

iFrame URL 생성

iFrame URL 생성은 단일 테넌트 설정과 동일한 패턴을 따르지만, 차이점은 백엔드가 요청한 사용자의 테넌트에 맞는 올바른 자격 증명과 기본 URL을 조회한다는 점이에요.

@app.get("/api/embed-url")
@cross_origin(origins="*", methods=["GET", "OPTIONS"], allow_headers=["Content-Type"])
def embed_url():
    user_id = get_authenticated_user_id()
    tenant_id = get_tenant_id_for_user(user_id)
    credential = get_credential_for_tenant(tenant_id)
    base_url = get_base_url_for_tenant(tenant_id)
    iframe_url = generate_secure_embed_url(credential, base_url)
    return jsonify({"iframeUrl": iframe_url})

임베드 생성 시점에 default_values가 각 테넌트로 한정되어 있기 때문에, 대시보드가 로드될 때 각 테넌트는 자신의 데이터만 볼 수 있어요.

제한 사항

자격 증명 회전 없음: 자격 증명을 분실하거나 유출했다면 보안 임베드를 삭제하고 새로 만들어야 해요. 그동안 조직의 Public Sharing Settings에서 Embed 공유 유형을 비활성화하는 것도 고려해요.

세션별 취소 없음: 보안 임베드에 대한 모든 브라우저 접근을 취소하려면 공유 대시보드를 삭제해요. 삭제 시 모든 활성 세션이 무효화돼요.

공유 유형 편집 불가: 기존 공유 대시보드를 Secure Embed 유형으로 바꾸거나 Secure Embed에서 다른 유형으로 변경할 수 없어요. 공유 유형을 바꾸려면 공유 대시보드를 삭제하고 새로 만들어야 해요.

문제 해결

/api/embed-url fetch의 CORS 오류

클라이언트 측 렌더링을 사용하고 프론트엔드가 임베드 URL을 가져올 때 CORS 오류가 발생한다면 다음을 확인해요:

  • 백엔드는 Access-Control-Allow-Origin을 *(모든 출처) 또는 요청하는 웹사이트의 정확한 출처로 설정해야 해요.
  • 프론트엔드 fetch에 credentials: 'include'가 포함된 경우 와일드카드 *는 Access-Control-Allow-Origin에 유효하지 않아요. 이때 백엔드는 정확한 요청 출처로 응답하고 Access-Control-Allow-Credentials: true 헤더도 포함해야 해요.

더 알아보기 (Learn more)