iop/packages/go/config/validate.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

535 lines
19 KiB
Go

package config
import (
"encoding/hex"
"fmt"
"net/url"
"strings"
"github.com/spf13/viper"
)
const (
MinCredentialLeaseTTLSeconds = 5
MaxCredentialLeaseTTLSeconds = 300
MinCredentialCacheSize = 1
MaxCredentialCacheSize = 4096
)
func validateNodeCredentialPlane(cfg *NodeConfig) error {
if cfg == nil || !cfg.CredentialPlane.Enabled {
return nil
}
if !cfg.Transport.TLS.Enabled {
return fmt.Errorf("credential_plane requires transport.tls")
}
if err := cfg.Transport.TLS.ValidateClient("edge"); err != nil {
return fmt.Errorf("transport.tls: %w", err)
}
if strings.TrimSpace(cfg.CredentialPlane.RecipientKeyID) == "" || strings.TrimSpace(cfg.CredentialPlane.RecipientPrivateKey) == "" {
return fmt.Errorf("credential_plane recipient_key_id and recipient_private_key are required")
}
if strings.TrimSpace(cfg.CredentialPlane.IssuerKeyID) == "" || strings.TrimSpace(cfg.CredentialPlane.IssuerPublicKey) == "" {
return fmt.Errorf("credential_plane issuer_key_id and issuer_public_key are required")
}
if cfg.CredentialPlane.ReplayCacheSize < MinCredentialCacheSize || cfg.CredentialPlane.ReplayCacheSize > MaxCredentialCacheSize {
return fmt.Errorf("credential_plane.replay_cache_size must be between %d and %d", MinCredentialCacheSize, MaxCredentialCacheSize)
}
return nil
}
func validateEdgeCredentialPlane(cfg *EdgeConfig) error {
if cfg == nil || !cfg.CredentialPlane.Enabled {
return nil
}
if cfg.CredentialPlane.LeaseTTLSeconds < MinCredentialLeaseTTLSeconds || cfg.CredentialPlane.LeaseTTLSeconds > MaxCredentialLeaseTTLSeconds {
return fmt.Errorf("credential_plane.lease_ttl_seconds must be between %d and %d", MinCredentialLeaseTTLSeconds, MaxCredentialLeaseTTLSeconds)
}
if cfg.CredentialPlane.LeaseCacheSize < MinCredentialCacheSize || cfg.CredentialPlane.LeaseCacheSize > MaxCredentialCacheSize {
return fmt.Errorf("credential_plane.lease_cache_size must be between %d and %d", MinCredentialCacheSize, MaxCredentialCacheSize)
}
if !cfg.TLS.Enabled {
return fmt.Errorf("credential_plane requires edge-node tls")
}
if err := cfg.TLS.ValidateServer("node"); err != nil {
return fmt.Errorf("tls: %w", err)
}
if !cfg.ControlPlane.Enabled || strings.TrimSpace(cfg.ControlPlane.WireAddr) == "" || !cfg.ControlPlane.TLS.Enabled {
return fmt.Errorf("credential_plane requires an enabled TLS control_plane connector")
}
if err := cfg.ControlPlane.TLS.ValidateClient("control-plane"); err != nil {
return fmt.Errorf("control_plane.tls: %w", err)
}
if cfg.OpenAI.Enabled {
if !cfg.OpenAI.TLS.Enabled {
return fmt.Errorf("credential_plane requires openai.tls when OpenAI ingress is enabled")
}
if err := cfg.OpenAI.TLS.ValidateHTTPServer(); err != nil {
return fmt.Errorf("openai.tls: %w", err)
}
}
if len(cfg.OpenAI.PrincipalTokens) != 0 || strings.TrimSpace(cfg.OpenAI.BearerToken) != "" || cfg.OpenAI.ProviderAuth.Enabled {
return fmt.Errorf("credential_plane cannot be combined with legacy principal or provider auth sources")
}
if source := managedRawProviderCredentialSource(cfg); source != "" {
return fmt.Errorf("credential_plane cannot be combined with raw provider credential source %s", source)
}
return nil
}
// managedRawProviderCredentialSource returns a safe config path for the first
// static provider credential source that would compete with a managed lease.
// It never includes a configured header or environment value.
func managedRawProviderCredentialSource(cfg *EdgeConfig) string {
for nodeIndex := range cfg.Nodes {
node := &cfg.Nodes[nodeIndex]
for providerIndex := range node.Providers {
provider := &node.Providers[providerIndex]
profile := provider.RuntimeProfile
effectiveAuthHeader := ""
if profile != nil {
effectiveAuthHeader = strings.TrimSpace(profile.Auth.Header)
}
for header := range provider.Headers {
if isCredentialHeaderName(header) || (effectiveAuthHeader != "" && strings.EqualFold(strings.TrimSpace(header), effectiveAuthHeader)) {
return fmt.Sprintf("nodes[%d].providers[%d].headers[%q]", nodeIndex, providerIndex, header)
}
}
for envIndex, entry := range provider.Env {
if isCredentialEnvironmentEntry(entry) {
return fmt.Sprintf("nodes[%d].providers[%d].env[%d]", nodeIndex, providerIndex, envIndex)
}
}
for argIndex, arg := range provider.Args {
if isCredentialArgument(arg) {
return fmt.Sprintf("nodes[%d].providers[%d].args[%d]", nodeIndex, providerIndex, argIndex)
}
}
if urlContainsUserInfo(provider.Endpoint) {
return fmt.Sprintf("nodes[%d].providers[%d].endpoint", nodeIndex, providerIndex)
}
if urlContainsUserInfo(provider.BaseURL) {
return fmt.Sprintf("nodes[%d].providers[%d].base_url", nodeIndex, providerIndex)
}
if profile != nil {
if urlContainsUserInfo(profile.BaseURL) {
return fmt.Sprintf("nodes[%d].providers[%d].profile[%q].base_url", nodeIndex, providerIndex, profile.ID)
}
for operation, rawURL := range profile.Operations {
if urlContainsUserInfo(rawURL) {
return fmt.Sprintf("nodes[%d].providers[%d].profile[%q].operations[%q]", nodeIndex, providerIndex, profile.ID, operation)
}
}
}
}
effectiveProfileAuthHeaders := collectEffectiveProfileAuthHeaders(node.Providers)
for header := range node.Adapters.OpenAICompat.Headers {
if isCredentialHeaderName(header) || matchesEffectiveProfileAuthHeader(header, effectiveProfileAuthHeaders) {
return fmt.Sprintf("nodes[%d].adapters.openai_compat.headers[%q]", nodeIndex, header)
}
}
for instanceIndex := range node.Adapters.OpenAICompatInstances {
instance := &node.Adapters.OpenAICompatInstances[instanceIndex]
for header := range instance.Headers {
if isCredentialHeaderName(header) || matchesEffectiveProfileAuthHeader(header, effectiveProfileAuthHeaders) {
return fmt.Sprintf("nodes[%d].adapters.openai_compat_instances[%d].headers[%q]", nodeIndex, instanceIndex, header)
}
}
if urlContainsUserInfo(instance.Endpoint) {
return fmt.Sprintf("nodes[%d].adapters.openai_compat_instances[%d].endpoint", nodeIndex, instanceIndex)
}
}
}
return ""
}
func collectEffectiveProfileAuthHeaders(providers []NodeProviderConf) map[string]struct{} {
headers := make(map[string]struct{})
for i := range providers {
profile := providers[i].RuntimeProfile
if profile != nil {
h := strings.TrimSpace(profile.Auth.Header)
if h != "" {
headers[strings.ToLower(h)] = struct{}{}
}
}
}
return headers
}
func matchesEffectiveProfileAuthHeader(header string, effective map[string]struct{}) bool {
if len(effective) == 0 {
return false
}
_, ok := effective[strings.ToLower(strings.TrimSpace(header))]
return ok
}
func isCredentialHeaderName(name string) bool {
normalized := strings.ToLower(strings.TrimSpace(name))
switch normalized {
case "authorization", "proxy-authorization", "x-api-key", "api-key", "x-auth", "x-auth-token":
return true
default:
return strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret")
}
}
func isCredentialEnvironmentEntry(entry string) bool {
name := entry
if index := strings.IndexByte(name, '='); index >= 0 {
name = name[:index]
}
return isCredentialName(name)
}
func isCredentialArgument(arg string) bool {
name := strings.TrimLeft(strings.TrimSpace(arg), "-")
if index := strings.IndexByte(name, '='); index >= 0 {
name = name[:index]
}
return isCredentialName(name)
}
func isCredentialName(name string) bool {
segments := strings.FieldsFunc(strings.ToUpper(strings.TrimSpace(name)), func(r rune) bool {
return (r < 'A' || r > 'Z') && (r < '0' || r > '9')
})
for index, segment := range segments {
switch segment {
case "APIKEY", "SECRET", "CREDENTIAL", "CREDENTIALS", "PASSWORD", "AUTHORIZATION":
return true
}
if segment == "TOKEN" && index == len(segments)-1 {
return true
}
if segment == "API" && index+1 < len(segments) && segments[index+1] == "KEY" {
return true
}
}
return false
}
func urlContainsUserInfo(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
return err == nil && parsed.User != nil
}
// validateOpenAIRoutes rejects duplicate and empty model ids in the route catalog.
func validateOpenAIRoutes(routes []OpenAIRouteEntry) error {
seen := make(map[string]struct{}, len(routes))
for i, r := range routes {
model := strings.TrimSpace(r.Model)
if model == "" {
return fmt.Errorf("openai.model_routes[%d]: model must not be empty", i)
}
if _, dup := seen[model]; dup {
return fmt.Errorf("openai.model_routes: duplicate model %q", model)
}
seen[model] = struct{}{}
if r.MaxQueue < 0 {
return fmt.Errorf("openai.model_routes[%d].max_queue must be non-negative", i)
}
if r.QueueTimeoutMS < 0 {
return fmt.Errorf("openai.model_routes[%d].queue_timeout_ms must be non-negative", i)
}
}
return nil
}
// validateOpenAIAttributionBindings requires every enabled direct OpenAI route
// to have a stable provider identity. A route-level provider_id overrides the
// top-level fallback, but the fallback itself remains independently
// dispatchable and therefore also requires a binding. Provider catalog
// cross-reference validation is deliberately not applied to legacy direct
// adapter routes.
func validateOpenAIAttributionBindings(openAI EdgeOpenAIConf) error {
if !openAI.Enabled {
return nil
}
fallbackProviderID := strings.TrimSpace(openAI.ProviderID)
if fallbackProviderID == "" {
return fmt.Errorf("openai.provider_id must not be empty when OpenAI is enabled")
}
for i, route := range openAI.ModelRoutes {
providerID := strings.TrimSpace(route.ProviderID)
if providerID == "" {
providerID = fallbackProviderID
}
if providerID == "" {
return fmt.Errorf("openai.model_routes[%d].provider_id must not be empty without openai.provider_id fallback", i)
}
}
return nil
}
// validateOpenAIPrincipalTokens validates the raw-token-free bearer token
// operation mapping. Each entry must resolve to a unique, 64-char lowercase
// hex SHA-256 hash and a non-empty principal_ref; token_ref must be unique.
func validateOpenAIPrincipalTokens(tokens []OpenAIPrincipalTokenConf) error {
seenTokenRef := make(map[string]struct{}, len(tokens))
seenHash := make(map[string]struct{}, len(tokens))
for i, t := range tokens {
ref := strings.TrimSpace(t.TokenRef)
if ref == "" {
return fmt.Errorf("openai.principal_tokens[%d]: token_ref must not be empty", i)
}
if _, dup := seenTokenRef[ref]; dup {
return fmt.Errorf("openai.principal_tokens: duplicate token_ref %q", ref)
}
seenTokenRef[ref] = struct{}{}
hash := strings.ToLower(strings.TrimSpace(t.TokenHashSHA256))
if len(hash) != 64 {
return fmt.Errorf("openai.principal_tokens[%q]: token_hash_sha256 must be a 64-character hex SHA-256 hash", ref)
}
if _, err := hex.DecodeString(hash); err != nil {
return fmt.Errorf("openai.principal_tokens[%q]: token_hash_sha256 must be hex-encoded: %w", ref, err)
}
if _, dup := seenHash[hash]; dup {
return fmt.Errorf("openai.principal_tokens: duplicate token_hash_sha256 for token_ref %q", ref)
}
seenHash[hash] = struct{}{}
if strings.TrimSpace(t.PrincipalRef) == "" {
return fmt.Errorf("openai.principal_tokens[%q]: principal_ref must not be empty", ref)
}
}
return nil
}
func normalizeOpenAIProviderAuth(v *viper.Viper, auth *EdgeOpenAIProviderAuthConf) error {
if !auth.Enabled {
return nil
}
if v.InConfig("openai.provider_auth.from_header") {
auth.FromHeader = strings.TrimSpace(auth.FromHeader)
if auth.FromHeader == "" {
return fmt.Errorf("openai.provider_auth.from_header must not be empty when provider_auth is enabled")
}
} else {
auth.FromHeader = "X-IOP-Provider-Authorization"
}
if v.InConfig("openai.provider_auth.target_header") {
auth.TargetHeader = strings.TrimSpace(auth.TargetHeader)
if auth.TargetHeader == "" {
return fmt.Errorf("openai.provider_auth.target_header must not be empty when provider_auth is enabled")
}
} else {
auth.TargetHeader = "Authorization"
}
auth.Scheme = strings.TrimSpace(auth.Scheme)
if auth.Scheme == "" {
auth.Scheme = "Bearer"
}
if !v.InConfig("openai.provider_auth.required") {
auth.Required = true
}
return nil
}
// validateProviderLongContextBudget enforces the long-context admission budget:
// every provider referenced by a model group with a positive context window must
// have total_context_tokens >= context_window_tokens * long_context_capacity.
// Providers with long_context_capacity == 0 declare no long-slot budget and are
// therefore unconstrained by this check.
func validateProviderLongContextBudget(m ModelCatalogEntry, providerByID map[string]NodeProviderConf) error {
if m.ContextWindowTokens <= 0 {
return nil
}
for pid := range m.Providers {
p := strings.TrimSpace(pid)
prov, ok := providerByID[p]
if !ok {
continue
}
if prov.LongContextCapacity <= 0 {
continue
}
required := m.ContextWindowTokens * prov.LongContextCapacity
if prov.TotalContextTokens < required {
return fmt.Errorf("providers[%q].total_context_tokens (%d) must be >= context_window_tokens (%d) * long_context_capacity (%d) = %d",
p, prov.TotalContextTokens, m.ContextWindowTokens, prov.LongContextCapacity, required)
}
}
return nil
}
// validateModelTokenCounter keeps token counting local and Chat-only. The
// deterministic mode takes no tuning parameter; estimate mode uses an explicit
// positive tokens-per-1k-characters rate bounded to one token per character.
func validateModelTokenCounter(m ModelCatalogEntry, providerByID map[string]NodeProviderConf) error {
if m.TokenCounter == nil {
return nil
}
counter := *m.TokenCounter
switch counter.Mode {
case TokenCounterDeterministic:
if counter.Per1kInput != 0 {
return fmt.Errorf("models[%q].token_counter.per_1k_input must be 0 in deterministic mode", m.ID)
}
case TokenCounterEstimate:
if counter.Per1kInput < 1 || counter.Per1kInput > 1000 {
return fmt.Errorf("models[%q].token_counter.per_1k_input must be between 1 and 1000 in estimate mode", m.ID)
}
default:
return fmt.Errorf("models[%q].token_counter.mode must be %q or %q, got %q", m.ID, TokenCounterDeterministic, TokenCounterEstimate, counter.Mode)
}
for providerID := range m.Providers {
provider, ok := providerByID[strings.TrimSpace(providerID)]
if !ok || provider.RuntimeProfile == nil {
continue
}
driver := provider.RuntimeProfile.Driver
if driver != ProtocolDriverOpenAIChat {
return fmt.Errorf("models[%q].token_counter is Chat-only; provider %q selects profile %q with driver %q", m.ID, providerID, provider.RuntimeProfile.ID, driver)
}
}
return nil
}
func checkUniqueNames(field string, name func(int) string, n int) error {
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
k := name(i)
if k == "" {
return fmt.Errorf("%s[%d]: name must not be empty", field, i)
}
if _, dup := seen[k]; dup {
return fmt.Errorf("%s: duplicate name %q", field, k)
}
seen[k] = struct{}{}
}
return nil
}
func validateProviderQueueConfig(field string, capacity, maxQueue, queueTimeoutMS, requestTimeoutMS int) error {
if capacity < 0 {
return fmt.Errorf("%s.capacity must be non-negative", field)
}
if maxQueue < 0 {
return fmt.Errorf("%s.max_queue must be non-negative", field)
}
if queueTimeoutMS < 0 {
return fmt.Errorf("%s.queue_timeout_ms must be non-negative", field)
}
if requestTimeoutMS < 0 {
return fmt.Errorf("%s.request_timeout_ms must be non-negative", field)
}
return nil
}
// CheckProviderLegacyConflict validates that when a provider-first resource
// declares an ID matching an enabled legacy adapter instance name, the
// provider's execution fields match the legacy adapter's fields. A mismatch
// produces a validation error to prevent silent precedence between the two
// config paths.
func CheckProviderLegacyConflict(nodeIdx int, nodeName string, node *NodeDefinition) error {
// Build a map of enabled adapter instance names to their configs per type.
legacyByKey := make(map[string]*legacyAdapterRef)
for _, inst := range node.Adapters.OllamaInstances {
if inst.Enabled {
legacyByKey[inst.Name] = &legacyAdapterRef{
Type: "ollama",
BaseURL: inst.BaseURL,
ContextSize: inst.ContextSize,
Capacity: inst.Capacity,
}
}
}
for _, inst := range node.Adapters.VllmInstances {
if inst.Enabled {
legacyByKey[inst.Name] = &legacyAdapterRef{
Type: "vllm",
Endpoint: inst.Endpoint,
Capacity: inst.Capacity,
}
}
}
for _, inst := range node.Adapters.OpenAICompatInstances {
if inst.Enabled {
legacyByKey[inst.Name] = &legacyAdapterRef{
Type: "openai_compat",
Provider: inst.Provider,
Endpoint: inst.Endpoint,
Headers: inst.Headers,
Capacity: inst.Capacity,
MaxQueue: inst.MaxQueue,
QueueTimeoutMS: inst.QueueTimeoutMS,
}
}
}
// CLI legacy: enabled cli adapter with its profiles.
if node.Adapters.CLI.Enabled {
for name, profile := range node.Adapters.CLI.Profiles {
legacyByKey[name] = &legacyAdapterRef{
Type: "cli",
Command: profile.Command,
Args: profile.Args,
}
}
}
for j, p := range node.Providers {
legacy, exists := legacyByKey[p.ID]
if !exists {
continue
}
normType := NormalizeProviderType(p.Type)
switch normType {
case "ollama":
if legacy.Type != "ollama" {
continue
}
if p.BaseURL != "" && p.BaseURL != legacy.BaseURL {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: base_url mismatch (provider=%q, legacy=%q)",
nodeIdx, j, p.ID, p.ID, p.BaseURL, legacy.BaseURL)
}
if p.ContextSize != 0 && p.ContextSize != legacy.ContextSize {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: context_size mismatch (provider=%d, legacy=%d)",
nodeIdx, j, p.ID, p.ID, p.ContextSize, legacy.ContextSize)
}
if p.Capacity != legacy.Capacity {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: capacity mismatch (provider=%d, legacy=%d)",
nodeIdx, j, p.ID, p.ID, p.Capacity, legacy.Capacity)
}
case "vllm", "openai_compat":
if legacy.Type != "vllm" && legacy.Type != "openai_compat" {
continue
}
if p.Endpoint != "" && p.Endpoint != legacy.Endpoint {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: endpoint mismatch (provider=%q, legacy=%q)",
nodeIdx, j, p.ID, legacy.Type, p.ID, p.Endpoint, legacy.Endpoint)
}
if p.Capacity != legacy.Capacity {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: capacity mismatch (provider=%d, legacy=%d)",
nodeIdx, j, p.ID, legacy.Type, p.ID, p.Capacity, legacy.Capacity)
}
case "cli":
if legacy.Type != "cli" {
continue
}
if p.Command != "" && p.Command != legacy.Command {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.cli profile %q: command mismatch (provider=%q, legacy=%q)",
nodeIdx, j, p.ID, p.ID, p.Command, legacy.Command)
}
}
}
return nil
}
type legacyAdapterRef struct {
Type string
Endpoint string
BaseURL string
Provider string
Headers map[string]string
ContextSize int
Capacity int
MaxQueue int
QueueTimeoutMS int
Command string
Args []string
}