diff --git a/pkg/plugin/implementation/dediregistry/README.md b/pkg/plugin/implementation/dediregistry/README.md index 0ba4e83..6e0a0cd 100644 --- a/pkg/plugin/implementation/dediregistry/README.md +++ b/pkg/plugin/implementation/dediregistry/README.md @@ -16,9 +16,9 @@ The DeDi Registry plugin implements the `RegistryLookup` interface to retrieve p registry: id: dediregistry config: - url: "https://dedi-wrapper.example.com/dedi" + url: "https://fabric.nfh.global/registry/dedi" registryName: "subscribers.beckn.one" - allowedParentNamespaces: "commerce-network.org,retail-collective.org" + allowedNetworkIDs: "commerce-network.org/prod,local-commerce.org/production" timeout: 30 retry_max: 3 retry_wait_min: 1s @@ -31,7 +31,7 @@ registry: |-----------|----------|-------------|---------| | `url` | Yes | DeDi wrapper API base URL (include /dedi path) | - | | `registryName` | Yes | Registry name for lookup path | - | -| `allowedParentNamespaces` | No | Allowlist of parent namespace domains for signature validation | - | +| `allowedNetworkIDs` | No | Allowlist of network membership IDs from `data.network_memberships` for signature validation | - | | `timeout` | No | Request timeout in seconds | Client default | | `retry_max` | No | Maximum number of retry attempts | 4 (library default) | | `retry_wait_min` | No | Minimum wait time between retries (e.g., "1s", "500ms") | 1s (library default) | @@ -39,15 +39,15 @@ registry: ## API Integration -### DeDi Wrapper API Format +### Beckn Registry API Format ``` GET {url}/lookup/{subscriber_id}/{registryName}/{key_id} ``` -**Example**: `https://dedi-wrapper.com/dedi/lookup/bpp.example.com/subscribers.beckn.one/key-1` +**Example**: `https://api.beckn.io/registry/dedi/lookup/bpp.example.com/subscribers.beckn.one/76EU7K8oC9EQbXPMRL5uw3KbmTxbg3YDXHvm9nVQpK2eGghASnwHzm` ### Authentication -**No authentication required** - DeDi wrapper API is public. +**No authentication required** - Beckn Registry API is public. ### Expected Response Format @@ -64,7 +64,7 @@ GET {url}/lookup/{subscriber_id}/{registryName}/{key_id} "signing_public_key": "384qqkIIpxo71WaJPsWqQNWUDGAFnfnJPxuDmtuBiLo=", "encr_public_key": "test-encr-key" }, - "parent_namespaces": ["commerce-network.org", "local-commerce.org"], + "network_memberships": ["commerce-network.org/prod", "local-commerce.org/production"], "created_at": "2025-10-27T11:45:27.963Z", "updated_at": "2025-10-27T11:46:23.563Z" } @@ -94,8 +94,9 @@ modules: registry: id: dediregistry config: - url: "https://dedi-wrapper.example.com/dedi" + url: "https://fabric.nfh.global/registry/dedi" registryName: "subscribers.beckn.one" + allowedNetworkIDs: "commerce-network.org/prod,local-commerce.org/production" timeout: 30 retry_max: 3 retry_wait_min: 1s @@ -148,6 +149,8 @@ This plugin replaces direct DeDi API integration with the new DeDi Wrapper API f - **Added**: Configurable registryName parameter - **Changed**: POST requests → GET requests - **Updated**: Response structure parsing (`data.details` object) +- **Updated**: Optional allowlist validation now checks `data.network_memberships` +- **Deprecated**: `allowedParentNamespaces` config key in favor of `allowedNetworkIDs` (plugin now errors until the config is updated to full network membership IDs) - **Added**: New URL path parameter format ## Dependencies diff --git a/pkg/plugin/implementation/dediregistry/cmd/plugin.go b/pkg/plugin/implementation/dediregistry/cmd/plugin.go index 5a0e31f..a70e3a6 100644 --- a/pkg/plugin/implementation/dediregistry/cmd/plugin.go +++ b/pkg/plugin/implementation/dediregistry/cmd/plugin.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "strconv" "strings" @@ -35,9 +36,11 @@ func (d dediRegistryProvider) New(ctx context.Context, config map[string]string) } } - if rawNamespaces, exists := config["allowedParentNamespaces"]; exists && rawNamespaces != "" { - dediConfig.AllowedParentNamespaces = parseAllowedParentNamespaces(rawNamespaces) + allowedNetworkIDs, err := resolveAllowedNetworkIDs(config) + if err != nil { + return nil, nil, err } + dediConfig.AllowedNetworkIDs = allowedNetworkIDs log.Debugf(ctx, "DeDi Registry config mapped: %+v", dediConfig) @@ -51,17 +54,31 @@ func (d dediRegistryProvider) New(ctx context.Context, config map[string]string) return dediClient, closer, nil } -func parseAllowedParentNamespaces(raw string) []string { +func parseAllowedNetworkIDs(raw string) []string { parts := strings.Split(raw, ",") - namespaces := make([]string, 0, len(parts)) + networkIDs := make([]string, 0, len(parts)) for _, part := range parts { item := strings.TrimSpace(part) if item == "" { continue } - namespaces = append(namespaces, item) + networkIDs = append(networkIDs, item) } - return namespaces + return networkIDs +} + +func resolveAllowedNetworkIDs(config map[string]string) ([]string, error) { + if rawParentNamespaces, exists := config["allowedParentNamespaces"]; exists && rawParentNamespaces != "" { + if _, hasAllowedNetworkIDs := config["allowedNetworkIDs"]; !hasAllowedNetworkIDs { + return nil, fmt.Errorf("config key 'allowedParentNamespaces' is no longer supported; use 'allowedNetworkIDs' with full network IDs") + } + } + + if rawNetworkIDs, exists := config["allowedNetworkIDs"]; exists && rawNetworkIDs != "" { + return parseAllowedNetworkIDs(rawNetworkIDs), nil + } + + return nil, nil } // Provider is the exported plugin instance diff --git a/pkg/plugin/implementation/dediregistry/cmd/plugin_test.go b/pkg/plugin/implementation/dediregistry/cmd/plugin_test.go index 7043fbd..4dafe8b 100644 --- a/pkg/plugin/implementation/dediregistry/cmd/plugin_test.go +++ b/pkg/plugin/implementation/dediregistry/cmd/plugin_test.go @@ -90,6 +90,80 @@ func TestDediRegistryProvider_New_InvalidTimeout(t *testing.T) { } } +func TestParseAllowedNetworkIDs(t *testing.T) { + got := parseAllowedNetworkIDs("commerce-network.org/prod, local-commerce.org/production, ,") + want := []string{ + "commerce-network.org/prod", + "local-commerce.org/production", + } + + if len(got) != len(want) { + t.Fatalf("expected %d allowed network IDs, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("expected allowedNetworkIDs[%d] to preserve input order as %q, got %q", i, want[i], got[i]) + } + } +} + +func TestResolveAllowedNetworkIDs_DeprecatedAllowedParentNamespacesErrorsWithoutAllowedNetworkIDs(t *testing.T) { + config := map[string]string{ + "allowedParentNamespaces": "commerce-network.org/prod, local-commerce.org/production", + } + + got, err := resolveAllowedNetworkIDs(config) + if err == nil { + t.Fatal("expected error when only allowedParentNamespaces is configured") + } + if got != nil { + t.Fatalf("expected nil allowed network IDs on error, got %#v", got) + } +} + +func TestResolveAllowedNetworkIDs_AllowedNetworkIDsTakesPrecedence(t *testing.T) { + config := map[string]string{ + "url": "https://test.com/dedi", + "registryName": "subscribers.beckn.one", + "allowedParentNamespaces": "deprecated-network.org/legacy", + "allowedNetworkIDs": "commerce-network.org/prod, local-commerce.org/production", + } + + got, err := resolveAllowedNetworkIDs(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + want := []string{ + "commerce-network.org/prod", + "local-commerce.org/production", + } + + if len(got) != len(want) { + t.Fatalf("expected %d allowed network IDs, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("expected allowedNetworkIDs[%d] = %q, got %q", i, want[i], got[i]) + } + } +} + +func TestDediRegistryProvider_New_DeprecatedAllowedParentNamespacesErrorsWithoutAllowedNetworkIDs(t *testing.T) { + ctx := context.Background() + provider := dediRegistryProvider{} + + config := map[string]string{ + "url": "https://test.com/dedi", + "registryName": "subscribers.beckn.one", + "allowedParentNamespaces": "commerce-network.org", + } + + _, _, err := provider.New(ctx, config) + if err == nil { + t.Fatal("expected New() to error when only allowedParentNamespaces is configured") + } +} + func TestDediRegistryProvider_New_NilContext(t *testing.T) { provider := dediRegistryProvider{} diff --git a/pkg/plugin/implementation/dediregistry/dediregistry.go b/pkg/plugin/implementation/dediregistry/dediregistry.go index 352342f..787e53a 100644 --- a/pkg/plugin/implementation/dediregistry/dediregistry.go +++ b/pkg/plugin/implementation/dediregistry/dediregistry.go @@ -15,13 +15,13 @@ import ( // Config holds configuration parameters for the DeDi registry client. type Config struct { - URL string `yaml:"url" json:"url"` - RegistryName string `yaml:"registryName" json:"registryName"` - AllowedParentNamespaces []string `yaml:"allowedParentNamespaces" json:"allowedParentNamespaces"` - Timeout int `yaml:"timeout" json:"timeout"` - RetryMax int `yaml:"retry_max" json:"retry_max"` - RetryWaitMin time.Duration `yaml:"retry_wait_min" json:"retry_wait_min"` - RetryWaitMax time.Duration `yaml:"retry_wait_max" json:"retry_wait_max"` + URL string `yaml:"url" json:"url"` + RegistryName string `yaml:"registryName" json:"registryName"` + AllowedNetworkIDs []string `yaml:"allowedNetworkIDs" json:"allowedNetworkIDs"` + Timeout int `yaml:"timeout" json:"timeout"` + RetryMax int `yaml:"retry_max" json:"retry_max"` + RetryWaitMin time.Duration `yaml:"retry_wait_min" json:"retry_wait_min"` + RetryWaitMax time.Duration `yaml:"retry_wait_max" json:"retry_wait_max"` } // DeDiRegistryClient encapsulates the logic for calling the DeDi registry endpoints. @@ -164,11 +164,11 @@ func (c *DeDiRegistryClient) Lookup(ctx context.Context, req *model.Subscription detailsDomain, _ := details["domain"].(string) detailsSubscriberID, _ := details["subscriber_id"].(string) - // Validate parent namespaces if configured - parentNamespaces := extractStringSlice(data["parent_namespaces"]) - if len(c.config.AllowedParentNamespaces) > 0 { - if len(parentNamespaces) == 0 || !containsAny(parentNamespaces, c.config.AllowedParentNamespaces) { - return nil, fmt.Errorf("registry entry with subscriber_id '%s' does not belong to any configured parent namespaces (registry.config.allowedParentNamespaces)", detailsSubscriberID) + // Validate network memberships if configured. + networkMemberships := extractStringSlice(ctx, "network_memberships", data["network_memberships"]) + if len(c.config.AllowedNetworkIDs) > 0 { + if len(networkMemberships) == 0 || !containsAny(networkMemberships, c.config.AllowedNetworkIDs) { + return nil, fmt.Errorf("registry entry with subscriber_id '%s' does not belong to any configured networks (registry.config.allowedNetworkIDs)", detailsSubscriberID) } } @@ -210,7 +210,7 @@ func parseTime(timeStr string) time.Time { return parsedTime } -func extractStringSlice(value interface{}) []string { +func extractStringSlice(ctx context.Context, fieldName string, value interface{}) []string { if value == nil { return nil } @@ -219,9 +219,10 @@ func extractStringSlice(value interface{}) []string { return v case []interface{}: out := make([]string, 0, len(v)) - for _, item := range v { + for i, item := range v { str, ok := item.(string) if !ok { + log.Warnf(ctx, "Ignoring invalid %s entry at index %d during registry lookup: expected a string network ID, got %T. This entry will not be considered for allowlist validation.", fieldName, i, item) continue } if str != "" { diff --git a/pkg/plugin/implementation/dediregistry/dediregistry_test.go b/pkg/plugin/implementation/dediregistry/dediregistry_test.go index 5e85673..6633298 100644 --- a/pkg/plugin/implementation/dediregistry/dediregistry_test.go +++ b/pkg/plugin/implementation/dediregistry/dediregistry_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "testing" "time" @@ -111,6 +112,33 @@ func TestNew(t *testing.T) { }) } +func TestExtractStringSlice(t *testing.T) { + ctx := context.Background() + + t.Run("returns strings from []string", func(t *testing.T) { + got := extractStringSlice(ctx, "network_memberships", []string{"commerce-network.org/prod", "local-commerce.org/production"}) + want := []string{"commerce-network.org/prod", "local-commerce.org/production"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("filters non-string entries from []interface{}", func(t *testing.T) { + got := extractStringSlice(ctx, "network_memberships", []interface{}{"commerce-network.org/prod", 42, true, "", "local-commerce.org/production"}) + want := []string{"commerce-network.org/prod", "local-commerce.org/production"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + }) + + t.Run("returns nil for unsupported type", func(t *testing.T) { + got := extractStringSlice(ctx, "network_memberships", "commerce-network.org/prod") + if got != nil { + t.Fatalf("expected nil, got %v", got) + } + }) +} + func TestLookup(t *testing.T) { ctx := context.Background() @@ -140,9 +168,9 @@ func TestLookup(t *testing.T) { "signing_public_key": "384qqkIIpxo71WaJPsWqQNWUDGAFnfnJPxuDmtuBiLo=", "encr_public_key": "test-encr-key", }, - "parent_namespaces": []string{"commerce-network.org", "local-commerce.org"}, - "created_at": "2025-10-27T11:45:27.963Z", - "updated_at": "2025-10-27T11:46:23.563Z", + "network_memberships": []string{"commerce-network.org/prod", "local-commerce.org/production"}, + "created_at": "2025-10-27T11:45:27.963Z", + "updated_at": "2025-10-27T11:46:23.563Z", }, } w.Header().Set("Content-Type", "application/json") @@ -192,7 +220,7 @@ func TestLookup(t *testing.T) { } }) - t.Run("allowed parent namespaces match", func(t *testing.T) { + t.Run("allowed network IDs match", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "message": "Record retrieved from registry cache", @@ -204,7 +232,7 @@ func TestLookup(t *testing.T) { "subscriber_id": "dev.np2.com", "signing_public_key": "384qqkIIpxo71WaJPsWqQNWUDGAFnfnJPxuDmtuBiLo=", }, - "parent_namespaces": []string{"commerce-network.org", "local-commerce.org"}, + "network_memberships": []string{"commerce-network.org/prod", "local-commerce.org/production"}, }, } w.Header().Set("Content-Type", "application/json") @@ -213,9 +241,9 @@ func TestLookup(t *testing.T) { defer server.Close() config := &Config{ - URL: server.URL + "/dedi", - RegistryName: "subscribers.beckn.one", - AllowedParentNamespaces: []string{"commerce-network.org"}, + URL: server.URL + "/dedi", + RegistryName: "subscribers.beckn.one", + AllowedNetworkIDs: []string{"commerce-network.org/prod"}, } client, closer, err := New(ctx, config) @@ -236,7 +264,7 @@ func TestLookup(t *testing.T) { } }) - t.Run("allowed parent namespaces mismatch", func(t *testing.T) { + t.Run("allowed network IDs mismatch", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "message": "Record retrieved from registry cache", @@ -248,7 +276,7 @@ func TestLookup(t *testing.T) { "subscriber_id": "dev.np2.com", "signing_public_key": "384qqkIIpxo71WaJPsWqQNWUDGAFnfnJPxuDmtuBiLo=", }, - "parent_namespaces": []string{"local-commerce.org"}, + "network_memberships": []string{"local-commerce.org/production"}, }, } w.Header().Set("Content-Type", "application/json") @@ -257,9 +285,9 @@ func TestLookup(t *testing.T) { defer server.Close() config := &Config{ - URL: server.URL + "/dedi", - RegistryName: "subscribers.beckn.one", - AllowedParentNamespaces: []string{"commerce-network.org"}, + URL: server.URL + "/dedi", + RegistryName: "subscribers.beckn.one", + AllowedNetworkIDs: []string{"commerce-network/subscriber-references"}, } client, closer, err := New(ctx, config) @@ -276,7 +304,55 @@ func TestLookup(t *testing.T) { } _, err = client.Lookup(ctx, req) if err == nil { - t.Error("Expected error for disallowed parent namespaces, got nil") + t.Error("Expected error for disallowed network memberships, got nil") + } + expectedErr := "registry entry with subscriber_id 'dev.np2.com' does not belong to any configured networks (registry.config.allowedNetworkIDs)" + if err.Error() != expectedErr { + t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) + } + }) + + t.Run("allowed network IDs match with mixed network membership types", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "message": "Record retrieved from registry cache", + "data": map[string]interface{}{ + "details": map[string]interface{}{ + "url": "http://dev.np2.com/beckn/bap", + "type": "BAP", + "domain": "energy", + "subscriber_id": "dev.np2.com", + "signing_public_key": "384qqkIIpxo71WaJPsWqQNWUDGAFnfnJPxuDmtuBiLo=", + }, + "network_memberships": []interface{}{123, "commerce-network.org/prod", map[string]interface{}{"invalid": true}}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + config := &Config{ + URL: server.URL + "/dedi", + RegistryName: "subscribers.beckn.one", + AllowedNetworkIDs: []string{"commerce-network.org/prod"}, + } + + client, closer, err := New(ctx, config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer closer() + + req := &model.Subscription{ + Subscriber: model.Subscriber{ + SubscriberID: "dev.np2.com", + }, + KeyID: "test-key-id", + } + _, err = client.Lookup(ctx, req) + if err != nil { + t.Errorf("Lookup() error = %v", err) } })