Kubernetes용 워크로드 아이덴티티 페더레이션 구성
Kubernetes용 워크로드 아이덴티티 페더레이션 구성
projected Kubernetes service account 토큰을 단기 OpenAI 액세스 토큰으로 교환해 Kubernetes를 Workload Identity Provider로 사용하세요.
출처: 문서
본문
Kubernetes 설정
이 가이드는 Kubernetes service account 토큰 프로젝션(projection)이 활성화되어 있다고 가정해요. 이는 현대 Kubernetes 릴리스에서 기본으로 제공돼요. OpenAI 워크로드 아이덴티티 페더레이션은 OIDC 호환 projected service account 토큰이 필요해요. Secrets에 저장된 레거시 Kubernetes service account 토큰은 지원되지 않아요.
OpenAI API를 호출해야 하는 워크로드에 Kubernetes ServiceAccount를 사용하세요. 아직 없으면 만드세요:
kubectl create serviceaccount openai-wif --namespace default
Kubernetes 클러스터의 OIDC 발행자를 얻으세요:
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
JWKS를 업로드하고 OpenAI가 OIDC 발행자에 대해 JWKS 디스커버리를 수행하지 않더라도, 이 발행자는 Workload Identity Provider에 구성된 발행자와 일치해야 해요.
클러스터 JWKS를 얻고 반환된 키 집합을 저장하세요. Workload Identity Provider를 구성할 때 필요해요:
kubectl get --raw /openid/v1/jwks
projected service account 토큰을 OpenAI가 기대하는 audience와 워크로드에 적합한 만료로 구성하세요. OpenAI는 토큰의 발행자, 서명, audience, 만료를 검증해요. 이 예시에서 토큰 파일은 /var/run/secrets/tokens/token에 마운트되고 audience https://api.openai.com/v1을 사용하며 3600초 후 만료돼요. projected 토큰 audience와 OpenAI Workload Identity Provider audience가 일치하면 다른 audience를 사용할 수 있어요:
apiVersion: v1
kind: Pod
metadata:
name: openai-wif-app
namespace: default
spec:
serviceAccountName: openai-wif
containers:
- name: app
image: my-image
volumeMounts:
- name: ksa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: ksa-token
projected:
sources:
- serviceAccountToken:
path: token
audience: "https://api.openai.com/v1"
expirationSeconds: 3600
토큰 검증
워크로드 아이덴티티 페더레이션을 구성하기 전에 샘플 projected service account 토큰을 로컬에서 디코딩하고 그 클레임을 검사하세요. projected 토큰이 마운트된 실행 중인 파드에서 토큰을 가져와 TOKEN으로 내보내세요:
TOKEN=$(kubectl exec -n default openai-wif-app -- cat /var/run/secrets/tokens/token)
export TOKEN
그런 다음 이 스크립트를 실행하세요:
const parts = process.env.TOKEN?.split(".") ?? [];
if (parts.length !== 3) {
throw new Error("Expected a compact JWT with three segments");
}
if (!/^[A-Za-z0-9_-]+$/.test(parts[1]) || parts[1].length % 4 === 1) {
throw new Error("JWT payload is not valid Base64URL");
}
const bytes = Buffer.from(parts[1], "base64url");
if (bytes.toString("base64url") !== parts[1]) {
throw new Error("JWT payload is not valid Base64URL");
}
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
const claims = JSON.parse(decoded);
if (claims === null || Array.isArray(claims) || typeof claims !== "object") {
throw new Error("JWT payload is not a JSON object");
}
console.log(decoded);
import base64
import json
import os
import re
def reject_non_json_constant(value):
raise ValueError(f"JWT payload contains non-JSON constant: {value}")
parts = os.environ.get("TOKEN", "").split(".")
if len(parts) != 3:
raise ValueError("Expected a compact JWT with three segments")
payload = parts[1]
if re.fullmatch(r"[A-Za-z0-9_-]+", payload) is None or len(payload) % 4 == 1:
raise ValueError("JWT payload is not valid Base64URL")
padded_payload = payload + "=" * (-len(payload) % 4)
decoded = base64.b64decode(padded_payload, altchars=b"-_", validate=True)
if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != payload:
raise ValueError("JWT payload is not valid Base64URL")
decoded_text = decoded.decode("utf-8")
claims = json.loads(decoded_text, parse_constant=reject_non_json_constant)
if not isinstance(claims, dict):
raise ValueError("JWT payload is not a JSON object")
print(decoded_text)
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"unicode/utf8"
)
func decodeSegment(segment string) (json.RawMessage, error) {
if !isBase64URLSegment(segment) {
return nil, fmt.Errorf("JWT segment is not valid Base64URL")
}
decoded, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return nil, err
}
if base64.RawURLEncoding.EncodeToString(decoded) != segment {
return nil, fmt.Errorf("JWT segment is not valid Base64URL")
}
if !utf8.Valid(decoded) {
return nil, fmt.Errorf("JWT segment is not valid UTF-8")
}
var value json.RawMessage
if err := json.Unmarshal(decoded, &value); err != nil {
return nil, err
}
if trimmed := bytes.TrimSpace(value); len(trimmed) == 0 || trimmed[0] != '{' {
return nil, fmt.Errorf("JWT segment is not a JSON object")
}
return value, nil
}
func isBase64URLSegment(segment string) bool {
if segment == "" || len(segment)%4 == 1 {
return false
}
for _, character := range segment {
if !('A' <= character && character <= 'Z') &&
!('a' <= character && character <= 'z') &&
!('0' <= character && character <= '9') &&
character != '-' &&
character != '_' {
return false
}
}
return true
}
func main() {
parts := strings.Split(os.Getenv("TOKEN"), ".")
if len(parts) != 3 {
panic("Expected a compact JWT with three segments")
}
payload, err := decodeSegment(parts[1])
if err != nil {
panic(err)
}
formatted, err := json.MarshalIndent(payload, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(formatted))
}
// Add Jackson (com.fasterxml.jackson.core:jackson-databind) to your project.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public final class DecodeJwtPayloadExample {
private static final ObjectMapper JSON =
new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private DecodeJwtPayloadExample() {}
static String decodeUtf8(byte[] bytes) throws IOException {
try {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException exception) {
throw new IOException("JWT segment is not valid UTF-8", exception);
}
}
static String decodeSegment(String segment) throws IOException {
if (!isBase64UrlSegment(segment)) {
throw new IllegalArgumentException("JWT segment is not valid Base64URL");
}
byte[] bytes = Base64.getUrlDecoder().decode(segment);
if (!Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).equals(segment)) {
throw new IllegalArgumentException("JWT segment is not valid Base64URL");
}
String decoded = decodeUtf8(bytes);
JsonNode value = JSON.readTree(decoded);
if (value == null || value.isMissingNode() || !value.isObject()) {
throw new IOException("JWT segment is not a JSON object");
}
return decoded;
}
static boolean isBase64UrlSegment(String segment) {
if (segment.isEmpty() || segment.length() % 4 == 1) {
return false;
}
return segment
.chars()
.allMatch(
character ->
character >= 'A' && character <= 'Z'
|| character >= 'a' && character <= 'z'
|| character >= '0' && character <= '9'
|| character == '-'
|| character == '_');
}
static String[] requireCompactJwt(String token) {
if (token == null) {
throw new IllegalArgumentException("Expected a compact JWT with three segments");
}
String[] parts = token.split("\\.", -1);
if (parts.length != 3) {
throw new IllegalArgumentException("Expected a compact JWT with three segments");
}
return parts;
}
public static void main(String[] args) throws IOException {
String[] parts = requireCompactJwt(System.getenv("TOKEN"));
System.out.println(decodeSegment(parts[1]));
}
}
using System.Text;
using System.Text.Json;
static string DecodeSegment(string segment)
{
if (
segment.Length % 4 == 1 ||
segment.Any(
character =>
!(
character is >= 'A' and <= 'Z' ||
character is >= 'a' and <= 'z' ||
character is >= '0' and <= '9' ||
character is '-' or '_'
)
)
)
{
throw new FormatException("JWT segment is not valid Base64URL");
}
byte[] decoded = Convert.FromBase64String(
segment.Replace('-', '+').Replace('_', '/') +
new string('=', (4 - segment.Length % 4) % 4)
);
string canonicalSegment = Convert
.ToBase64String(decoded)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
if (canonicalSegment != segment)
{
throw new FormatException("JWT segment is not valid Base64URL");
}
string decodedJson = new UTF8Encoding(false, true).GetString(decoded);
using JsonDocument document = JsonDocument.Parse(decodedJson);
if (document.RootElement.ValueKind is not JsonValueKind.Object)
{
throw new FormatException("JWT segment is not a JSON object");
}
return decodedJson;
}
string? token = Environment.GetEnvironmentVariable("TOKEN");
if (token is null)
{
throw new InvalidOperationException(
"Expected a compact JWT with three segments"
);
}
string[] parts = token.Split('.');
if (parts.Length != 3)
{
throw new InvalidOperationException(
"Expected a compact JWT with three segments"
);
}
Console.WriteLine(DecodeSegment(parts[1]));
require "base64"
require "json"
parts = ENV.fetch("TOKEN", "").split(".", -1)
raise "Expected a compact JWT with three segments" unless parts.length == 3
unless parts[1].match?(/\A[A-Za-z0-9_-]+\z/) && parts[1].length % 4 != 1
raise "JWT payload is not valid Base64URL"
end
begin
payload = Base64.urlsafe_decode64(parts[1].ljust((parts[1].length + 3) & ~3, "="))
rescue ArgumentError
raise "JWT payload is not valid Base64URL"
end
unless Base64.urlsafe_encode64(payload, padding: false) == parts[1]
raise "JWT payload is not valid Base64URL"
end
payload.force_encoding(Encoding::UTF_8)
raise "JWT payload is not valid UTF-8" unless payload.valid_encoding?
claims = JSON.parse(payload)
raise "JWT payload is not a JSON object" unless claims.is_a?(Hash)
puts(payload)
이 명령은 토큰 서명을 검증하지 않고 JWT 페이로드를 디코딩해요. 프로덕션 토큰에는 로컬 디코더를 사용하고 프로덕션 토큰을 제3자 도구에 붙여넣지 마세요.
디코딩된 Kubernetes projected service account 토큰은 비슷하게 보여요:
{
"iss": "https://kubernetes.example.com",
"aud": ["https://api.openai.com/v1"],
"sub": "system:serviceaccount:default:openai-wif",
"iat": 1716235422,
"exp": 1716239022,
"kubernetes.io": {
"namespace": "default",
"serviceaccount": {
"name": "openai-wif",
"uid": "11111111-2222-3333-4444-555555555555"
}
}
}
디코딩된 페이로드를 사용해 받은 토큰을 OpenAI에 구성된 발행자, audience, 매핑 값과 비교하세요. 대부분의 구성 문제는 토큰을 교환하기 전에 iss, aud, sub 클레임에서 보여요.
워크로드 아이덴티티 페더레이션 설정
Kubernetes 발행자에 대해 OpenAI에 Workload Identity Provider를 만든 다음 projected 토큰의 속성과 일치하는 서비스 계정 매핑을 추가하세요.
먼저 Workload Identity Provider를 구성한 다음 서비스 계정 매핑을 만드세요.
Workload Identity Provider 설정
-
Workload Identity Provider 생성. Name을
kubernetes-prod같은 고유 값으로 설정하세요. Description(예:Production Kubernetes cluster)으로 관리자가 클러스터를 식별하도록 도와주세요. -
발행자와 audience 설정. OIDC Issuer URL을
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer가 반환한 발행자로 설정하세요. 이 값은 projected 토큰의iss클레임과 일치해야 해요. Audience를 projected service account 토큰 볼륨에 구성된 같은 불투명 audience 문자열로 설정하세요. 이 예시에서 그 값은https://api.openai.com/v1이에요. -
Kubernetes JWKS 업로드. Use uploaded JWKS for token verification을 활성화한 다음 JWKS JSON을
kubectl get --raw /openid/v1/jwks의 출력으로 설정하세요. OpenAI는 이 공개 키 집합을 사용해 projected Kubernetes service account 토큰을 검증해요. 주변keys를 포함한 전체 키 집합을 업로드하세요.참고: 자체 호스팅 Kubernetes 클러스터의 경우 OpenAI는 로컬 JWKS 모드만 지원해요. 클러스터가 반환한 JWKS를 업로드하세요. OpenAI는 구성된 발행자에 대해 OIDC 디스커버리를 수행하지 않아요. OpenAI는 여전히 구성된 발행자를 토큰의
iss필드와 비교해요.클러스터가 service account 서명 키를 회전하면 Workload Identity Provider 구성에서 업로드된 JWKS를 업데이트하세요. 구성된 JWKS에 없는 키로 서명된 토큰은 거부돼요. JWKS에 여러 활성 공개 키가 포함되어 있으면 전체
keys배열을 포함하세요. -
파생 매핑 속성이 필요할 때만 속성 변환 추가.
sub,aud,iss같은 원시 토큰 클레임은 매핑 assertion에 직접 사용할 수 있어요. 원시 토큰 클레임보다는 변환된 속성에서 일치시킬 계획이라면 대시보드가openai.접두사를 자동으로 적용해요. 예를 들어openai.workload_subject를 만들려면workload_subject와assertion.sub표현식을 입력하세요. 이미openai.로 시작하는 원시 토큰 클레임은 일치하는 변환이 구성되지 않으면openai.매핑 키에 대해 무시돼요.
서비스 계정 매핑 설정
-
서비스 계정 매핑 생성. Workload Identity Provider 내에서 Name을
openai-mapping-kubernetes같은 고유 값으로 설정하세요. Description(예:Workload Identity Provider Mapping for Kubernetes Workloads)으로 어떤 워크로드가 매핑을 사용할 수 있는지 설명하세요. -
Kubernetes service account subject 일치. Key를
sub로, Value를system:serviceaccount:default:openai-wif로 설정하세요. Kubernetes service account의 subject 형식은system:serviceaccount:<namespace>:<service-account-name>이에요. -
OpenAI 대상 선택. Project를 대상 서비스 계정을 소유한 OpenAI 프로젝트로 설정하세요. Service account를 Kubernetes 워크로드가 사용할 수 있는 OpenAI 서비스 계정(예:
kubernetes-prod-openai-wif)으로 설정하세요. 이 매핑을 위해 기존 계정을 재사용하기보다 새 서비스 계정을 만들고 싶으면Create a new service account in this project를 선택하세요. -
필요하면 API 권한 좁히기. 이 매핑에서 만들어진 액세스 토큰을 더 좁히려면
api.model.request,api.vector_store.read같은 적절한 Permissions를 선택하세요. WIF 특정 스코프 제한을 추가하지 않으려면 권한을 비워 두세요. 토큰은 여전히 매핑된 서비스 계정으로 승인돼요.
코드에서 토큰 사용
OpenAI SDK 클라이언트를 구성해 projected Kubernetes 토큰을 읽고 OpenAI가 발행한 액세스 토큰으로 교환하세요.
SDK 워크로드 아이덴티티 페더레이션 제공자의 subject 토큰 소스로 /var/run/secrets/tokens/token 같은 마운트된 토큰 경로를 사용하세요. SDK는 그 Kubernetes 토큰을 OpenAI가 발행한 액세스 토큰으로 교환하고 OpenAI 토큰을 사용해 API 요청을 인증해요.
다음 예시는 커스텀 subject 토큰 제공자로 OpenAI 클라이언트를 초기화해요. 제공자는 마운트된 파일 경로에서 projected Kubernetes service account 토큰을 읽고 워크로드 아이덴티티 페더레이션의 subject 토큰으로 사용해요.
Kubernetes projected service account 토큰으로 인증
import { readFile } from "node:fs/promises";
import OpenAI from "openai";
const tokenPath = "/var/run/secrets/tokens/token";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;
if (!identityProviderId || !serviceAccountId) {
throw new Error(
"Set OPENAI_IDENTITY_PROVIDER_ID and OPENAI_SERVICE_ACCOUNT_ID"
);
}
function mountedServiceAccountTokenProvider(path) {
return {
tokenType: "jwt",
getToken: async () => {
const token = (await readFile(path, "utf8")).trim();
if (!token) {
throw new Error("The mounted service account token file is empty.");
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: mountedServiceAccountTokenProvider(tokenPath),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from Kubernetes workload identity federation.",
});
console.log(response.output_text);
import os
from pathlib import Path
from openai import OpenAI
from openai.auth import SubjectTokenProvider
TOKEN_PATH = "/var/run/secrets/tokens/token"
def mounted_service_account_token_provider(token_path: str) -> SubjectTokenProvider:
def get_token() -> str:
token = Path(token_path).read_text().strip()
if not token:
raise RuntimeError("The mounted service account token file is empty.")
return token
return {"token_type": "jwt", "get_token": get_token}
client = OpenAI(
workload_identity={
"identity_provider_id": os.environ["OPENAI_IDENTITY_PROVIDER_ID"],
"service_account_id": os.environ["OPENAI_SERVICE_ACCOUNT_ID"],
"provider": mounted_service_account_token_provider(TOKEN_PATH),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from Kubernetes workload identity federation.",
)
print(response.output_text)
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/auth"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
)
const tokenPath = "/var/run/secrets/tokens/token"
type mountedServiceAccountTokenProvider struct {
path string
}
func (p mountedServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p mountedServiceAccountTokenProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {
data, err := os.ReadFile(p.path)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "kubernetes",
Message: "failed to read mounted service account token",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "kubernetes",
Message: "mounted service account token is empty",
}
}
return token, nil
}
func main() {
client := openai.NewClient(
option.WithWorkloadIdentity(auth.WorkloadIdentity{
IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),
ServiceAccountID: os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),
Provider: mountedServiceAccountTokenProvider{
path: tokenPath,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from Kubernetes workload identity federation."),
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(response.OutputText())
}
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.openai.auth.SubjectTokenProvider;
import com.openai.auth.SubjectTokenType;
import com.openai.auth.WorkloadIdentity;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.core.http.HttpClient;
import com.openai.errors.SubjectTokenProviderException;
import com.openai.models.responses.ResponseCreateParams;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
public final class KubernetesWorkloadIdentityExample {
private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";
private KubernetesWorkloadIdentityExample() {}
static final class MountedServiceAccountTokenProvider implements SubjectTokenProvider {
private final Path tokenPath;
MountedServiceAccountTokenProvider(String tokenPath) {
this.tokenPath = Path.of(tokenPath);
}
@Override
public SubjectTokenType tokenType() {
return SubjectTokenType.JWT;
}
@Override
public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {
String token;
try {
token = Files.readString(tokenPath).trim();
} catch (Exception e) {
throw new SubjectTokenProviderException(
"kubernetes", "failed to read mounted service account token", e);
}
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"kubernetes", "mounted service account token is empty", null);
}
return token;
}
@Override
public CompletableFuture<String> getTokenAsync(HttpClient httpClient, JsonMapper jsonMapper) {
return CompletableFuture.supplyAsync(() -> getToken(httpClient, jsonMapper));
}
}
public static void main(String[] args) {
WorkloadIdentity workloadIdentity =
WorkloadIdentity.builder()
.identityProviderId(System.getenv("OPENAI_IDENTITY_PROVIDER_ID"))
.serviceAccountId(System.getenv("OPENAI_SERVICE_ACCOUNT_ID"))
.provider(new MountedServiceAccountTokenProvider(TOKEN_PATH))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from Kubernetes workload identity federation.")
.build();
client.responses().create(params).output().stream()
.flatMap(item -> item.message().stream())
.flatMap(message -> message.content().stream())
.flatMap(content -> content.outputText().stream())
.forEach(outputText -> System.out.println(outputText.text()));
}
}
require "openai"
TOKEN_PATH = "/var/run/secrets/tokens/token"
class MountedServiceAccountTokenProvider
include OpenAI::Auth::SubjectTokenProvider
def initialize(token_path:)
@token_path = token_path
end
def token_type
OpenAI::Auth::TokenType::JWT
end
def get_token
token = File.read(@token_path).strip
if token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Mounted service account token is empty",
provider: "kubernetes"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to read mounted service account token: #{e.message}",
provider: "kubernetes",
cause: e
)
end
end
provider = MountedServiceAccountTokenProvider.new(token_path: TOKEN_PATH)
workload_identity = OpenAI::Auth::WorkloadIdentity.new(
identity_provider_id: ENV.fetch("OPENAI_IDENTITY_PROVIDER_ID"),
service_account_id: ENV.fetch("OPENAI_SERVICE_ACCOUNT_ID"),
provider: provider
)
client = OpenAI::Client.new(workload_identity: workload_identity)
response = client.responses.create(
model: "gpt-5.6-terra",
input: "Say hello from Kubernetes workload identity federation."
)
puts(response.output_text)
Kubernetes 모범 사례
- 안정적인 OIDC 발행자를 사용하세요. 발행자 URL은 projected service account 토큰
iss클레임과 일치해야 하고 클러스터 업그레이드와 유지보수 작업 전반에 걸쳐 안정적으로 유지되어야 해요. - 서명 키를 조심스럽게 보호하세요. 클러스터의 service account 서명 키에 접근할 수 있는 사람은 OpenAI가 수락할 수도 있는 토큰을 만들 수 있어요.
- OpenAI 통합에는 전용 service account를 사용하세요. 관련 없는 인프라나 애플리케이션 접근에도 사용되는 service account를 재사용하지 마세요.
- 업로드된 JWKS를 최신으로 유지하세요. OpenAI는 로컬 JWKS 모드에서 구성된 JWKS를 사용해 워크로드 아이덴티티 토큰을 검증하므로 새 서명 키로 회전하기 전에 Workload Identity Provider를 업데이트하세요.
- 커스텀 클레임 복잡성을 최소화하세요.
sub,aud같은 표준 클레임 또는 그 클레임에서 직접 파생된 변환 속성에서 일치시키는 것을 선호하세요. - 네임스페이스 소유권을 보안 모델의 일부로 취급하세요. 네임스페이스 관리자가 service account를 만들 수 있다면 의도치 않은 권한 상승을 방지하기 위해 매핑이 적절히 범위가 지정되도록 보장하세요.
- 발행자와 서명 키 변경을 모니터링하세요. Workload Identity Provider JWKS를 업데이트하지 않고 서명 키를 회전하면 토큰 교환 실패가 발생할 수 있어요.