Merge pull request #641 from nirmalnr/feat/network_allowlist

Add network ID allowlist support to DeDi registry plugin
This commit is contained in:
Mayuresh A Nirhali
2026-04-02 12:03:48 +05:30
committed by GitHub
5 changed files with 213 additions and 42 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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{}

View File

@@ -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 != "" {

View File

@@ -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)
}
})