From 3ab19630d290b582e7100bbd30192d34943312be Mon Sep 17 00:00:00 2001 From: Francisco Meneses Date: Thu, 11 Jun 2026 17:36:35 -0400 Subject: [PATCH] fix(visualize): infer auth type from YAML and legacy proxy fields Resolve API Key, App ID + App Key, and OIDC from toolbox product YAML and Admin API proxy sidecars when auth_type is absent from proxy.json. Co-authored-by: Cursor --- internal/visualize/auth.go | 188 ++++++++++++++++++++++ internal/visualize/auth_test.go | 178 ++++++++++++++++++++ internal/visualize/loader.go | 25 ++- internal/visualize/loader_helpers_test.go | 3 +- internal/visualize/report.go | 2 +- 5 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 internal/visualize/auth.go create mode 100644 internal/visualize/auth_test.go diff --git a/internal/visualize/auth.go b/internal/visualize/auth.go new file mode 100644 index 0000000..2c8a4c0 --- /dev/null +++ b/internal/visualize/auth.go @@ -0,0 +1,188 @@ +package visualize + +import ( + "encoding/json" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +func resolveProductAuthType(product *Product, yamlPath string) { + if product == nil { + return + } + + if fromYAML, err := readAuthTypeFromYAML(yamlPath); err == nil && fromYAML != "" { + product.AuthType = fromYAML + return + } + + authType := normalizeAuthType(product.AuthType) + if authType == "" && product.OIDC != nil && strings.TrimSpace(product.OIDC.IssuerEndpoint) != "" { + authType = "oidc" + } + product.AuthType = authType +} + +func readAuthTypeFromYAML(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + spec, ok := productSpecFromYAML(data) + if !ok { + return "", nil + } + return authTypeFromProductSpec(spec), nil +} + +func productSpecFromYAML(data []byte) (map[string]any, bool) { + var root map[string]any + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, false + } + + kind, _ := root["kind"].(string) + switch kind { + case "Product": + spec, _ := root["spec"].(map[string]any) + return spec, spec != nil + case "List": + items, _ := root["items"].([]any) + for _, item := range items { + entry, _ := item.(map[string]any) + if entry == nil { + continue + } + if entryKind, _ := entry["kind"].(string); entryKind != "Product" { + continue + } + spec, _ := entry["spec"].(map[string]any) + if spec != nil { + return spec, true + } + } + } + return nil, false +} + +func authTypeFromProductSpec(spec map[string]any) string { + deployment, _ := spec["deployment"].(map[string]any) + if deployment == nil { + return "" + } + apicast, _ := deployment["apicastHosted"].(map[string]any) + if apicast == nil { + return "" + } + authentication, _ := apicast["authentication"].(map[string]any) + if authentication == nil { + return "" + } + if _, ok := authentication["oidc"]; ok { + return "oidc" + } + if _, ok := authentication["userkey"]; ok { + return "api_key" + } + if _, ok := authentication["userKey"]; ok { + return "api_key" + } + if _, ok := authentication["appKeyAppID"]; ok { + return "app_id_and_app_key" + } + return "" +} + +func inferAuthTypeFromProxy(authType, userKey, appID, appKey, oidcIssuerType, oidcIssuerEndpoint string, policiesConfig json.RawMessage) string { + if t := normalizeAuthType(authType); t != "" { + return t + } + if strings.TrimSpace(oidcIssuerEndpoint) != "" { + return "oidc" + } + if fromPolicy := authTypeFromPoliciesConfig(policiesConfig); fromPolicy != "" { + return fromPolicy + } + + userEnabled := proxyAuthModeEnabled(userKey) + appIDEnabled := proxyAuthModeEnabled(appID) + appKeyEnabled := proxyAuthModeEnabled(appKey) + + if strings.EqualFold(strings.TrimSpace(userKey), "true") && strings.EqualFold(strings.TrimSpace(appID), "false") { + return "api_key" + } + if strings.EqualFold(strings.TrimSpace(appID), "true") && strings.EqualFold(strings.TrimSpace(userKey), "false") { + return "app_id_and_app_key" + } + if userEnabled && !appIDEnabled && !appKeyEnabled { + return "api_key" + } + if appIDEnabled && appKeyEnabled { + return "app_id_and_app_key" + } + if userEnabled { + return "api_key" + } + return "" +} + +func authTypeFromPoliciesConfig(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var policies []struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Configuration struct { + AuthType string `json:"auth_type"` + } `json:"configuration"` + } + if err := json.Unmarshal(raw, &policies); err != nil { + return "" + } + for _, policy := range policies { + if policy.Name != "default_credentials" { + continue + } + if t := normalizeAuthType(policy.Configuration.AuthType); t != "" { + if policy.Enabled { + return t + } + } + } + for _, policy := range policies { + if policy.Name != "default_credentials" { + continue + } + if t := normalizeAuthType(policy.Configuration.AuthType); t != "" { + return t + } + } + return "" +} + +func proxyAuthModeEnabled(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "false": + return false + default: + return true + } +} + +func normalizeAuthType(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return "" + case "api_key", "user_key", "apikey", "userkey": + return "api_key" + case "app_key", "app_id", "app_id_and_app_key", "app_key_and_app_id": + return "app_id_and_app_key" + case "oidc", "openid_connect": + return "oidc" + default: + return strings.ToLower(strings.TrimSpace(raw)) + } +} diff --git a/internal/visualize/auth_test.go b/internal/visualize/auth_test.go new file mode 100644 index 0000000..e0e2f36 --- /dev/null +++ b/internal/visualize/auth_test.go @@ -0,0 +1,178 @@ +package visualize + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInferAuthTypeFromProxyLegacyFields(t *testing.T) { + cases := []struct { + name string + userKey string + appID string + appKey string + oidcURL string + want string + }{ + {name: "api key booleans", userKey: "true", appID: "false", want: "api_key"}, + {name: "app id booleans", userKey: "false", appID: "true", appKey: "app_key", want: "app_id_and_app_key"}, + {name: "copec param names", userKey: "user_key", appID: "app_id", appKey: "app_key", want: "app_id_and_app_key"}, + {name: "oidc issuer", oidcURL: "https://sso.example.com/realm/demo", want: "oidc"}, + {name: "explicit auth type", userKey: "true", appID: "false", want: "api_key"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := inferAuthTypeFromProxy("", tc.userKey, tc.appID, tc.appKey, "keycloak", tc.oidcURL, nil) + if got != tc.want { + t.Fatalf("inferAuthTypeFromProxy() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestInferAuthTypeFromProxyDefaultCredentialsPolicy(t *testing.T) { + raw := []byte(`[ + {"name":"apicast","enabled":true,"configuration":{}}, + {"name":"default_credentials","enabled":true,"configuration":{"auth_type":"user_key"}} + ]`) + got := inferAuthTypeFromProxy("", "app_id", "app_key", "user_key", "", "", raw) + if got != "api_key" { + t.Fatalf("got %q, want api_key", got) + } +} + +func TestReadAuthTypeFromYAMLListProduct(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "B2B-IS.yaml") + content := `apiVersion: v1 +kind: List +items: +- apiVersion: capabilities.3scale.net/v1beta1 + kind: Product + spec: + deployment: + apicastHosted: + authentication: + appKeyAppID: + appID: app_id + appKey: app_key +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + got, err := readAuthTypeFromYAML(path) + if err != nil { + t.Fatal(err) + } + if got != "app_id_and_app_key" { + t.Fatalf("got %q, want app_id_and_app_key", got) + } +} + +func TestReadAuthTypeFromYAMLUserKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "Comisiones.yaml") + content := `apiVersion: v1 +kind: List +items: +- kind: Product + spec: + deployment: + apicastHosted: + authentication: + userkey: + authUserKey: user_key +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + got, err := readAuthTypeFromYAML(path) + if err != nil { + t.Fatal(err) + } + if got != "api_key" { + t.Fatalf("got %q, want api_key", got) + } +} + +func TestLoadExportCopecStyleProxy(t *testing.T) { + dir := t.TempDir() + writeMinimalManifest(t, dir, false) + writeJSON(t, filepath.Join(dir, "backends", "billing.json"), map[string]any{ + "id": 1, "system_name": "billing", "name": "billing", + }) + productDir := filepath.Join(dir, "products", "demo") + if err := os.MkdirAll(productDir, 0o755); err != nil { + t.Fatal(err) + } + writeJSON(t, filepath.Join(productDir, "proxy.json"), map[string]any{ + "proxy": map[string]any{ + "service_id": 10, + "auth_app_id": "app_id", + "auth_app_key": "app_key", + "auth_user_key": "user_key", + "endpoint": "https://demo.example.com", + }, + }) + yaml := `apiVersion: v1 +kind: List +items: +- kind: Product + spec: + name: Demo Product + systemName: demo + deployment: + apicastHosted: + authentication: + userkey: + authUserKey: user_key +` + if err := os.WriteFile(filepath.Join(dir, "products", "demo.yaml"), []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + + tenant, err := LoadExport(dir) + if err != nil { + t.Fatal(err) + } + product := findProduct(t, tenant, "demo") + if product.AuthType != "api_key" { + t.Fatalf("AuthType = %q, want api_key", product.AuthType) + } +} + +func writeMinimalManifest(t *testing.T, dir string, includeApps bool) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "backends"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "products"), 0o755); err != nil { + t.Fatal(err) + } + manifest := map[string]any{ + "schema_version": "1.0", + "exported_at": "2026-06-05T12:00:00Z", + "admin_url": "https://tenant-admin.example.com", + "product_count": 1, + "backend_count": 1, + "include_applications": includeApps, + "incomplete": false, + } + writeJSON(t, filepath.Join(dir, "manifest.json"), manifest) +} + +func writeJSON(t *testing.T, path string, payload any) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/visualize/loader.go b/internal/visualize/loader.go index 688014e..28d4adb 100644 --- a/internal/visualize/loader.go +++ b/internal/visualize/loader.go @@ -186,6 +186,7 @@ func loadProduct(productsDir, systemName string) (Product, error) { }); err != nil { return Product{}, err } + resolveProductAuthType(&product, filepath.Join(productsDir, systemName+".yaml")) if product.AuthType == "oidc" && product.OIDC == nil { if _, err := os.Stat(oidcPath); os.IsNotExist(err) { product.MissingFiles = append(product.MissingFiles, "oidc_configuration.json") @@ -234,17 +235,31 @@ func readOptionalJSON(path string, fn func([]byte) error) error { func parseProxy(data []byte, product *Product) error { var envelope struct { Proxy struct { - ServiceID int `json:"service_id"` - AuthType string `json:"auth_type"` - StagingEndpoint string `json:"staging_endpoint"` - ProductionEndpoint string `json:"endpoint"` + ServiceID int `json:"service_id"` + AuthType string `json:"auth_type"` + AuthUserKey string `json:"auth_user_key"` + AuthAppID string `json:"auth_app_id"` + AuthAppKey string `json:"auth_app_key"` + OIDCIssuerType string `json:"oidc_issuer_type"` + OIDCIssuerEndpoint string `json:"oidc_issuer_endpoint"` + PoliciesConfig json.RawMessage `json:"policies_config"` + StagingEndpoint string `json:"staging_endpoint"` + ProductionEndpoint string `json:"endpoint"` } `json:"proxy"` } if err := json.Unmarshal(data, &envelope); err != nil { return err } product.ServiceID = envelope.Proxy.ServiceID - product.AuthType = envelope.Proxy.AuthType + product.AuthType = inferAuthTypeFromProxy( + envelope.Proxy.AuthType, + envelope.Proxy.AuthUserKey, + envelope.Proxy.AuthAppID, + envelope.Proxy.AuthAppKey, + envelope.Proxy.OIDCIssuerType, + envelope.Proxy.OIDCIssuerEndpoint, + envelope.Proxy.PoliciesConfig, + ) product.StagingEndpoint = envelope.Proxy.StagingEndpoint product.ProductionEndpoint = envelope.Proxy.ProductionEndpoint return nil diff --git a/internal/visualize/loader_helpers_test.go b/internal/visualize/loader_helpers_test.go index 3155880..11f0b94 100644 --- a/internal/visualize/loader_helpers_test.go +++ b/internal/visualize/loader_helpers_test.go @@ -92,7 +92,8 @@ func TestAuthLabel(t *testing.T) { "app_id_and_app_key": "App ID + App Key", "oidc": "OIDC", "": "unknown", - "custom_auth": "custom_auth", + "user_key": "API Key", + "userkey": "API Key", } for in, want := range cases { if got := authLabel(in); got != want { diff --git a/internal/visualize/report.go b/internal/visualize/report.go index 5ee719c..a0784a1 100644 --- a/internal/visualize/report.go +++ b/internal/visualize/report.go @@ -312,7 +312,7 @@ func productApplications(t *Tenant, systemName string) []Application { func authLabel(authType string) string { switch authType { - case "api_key", "api_key_and_app_id": + case "api_key", "api_key_and_app_id", "user_key", "userkey": return "API Key" case "app_key", "app_id_and_app_key": return "App ID + App Key"