Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 11 additions & 190 deletions service/entityresolution/multi-strategy/output_mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ package multistrategy

import (
"errors"
"fmt"
"strings"

"github.com/opentdf/platform/service/entityresolution/multi-strategy/transformation"
"github.com/opentdf/platform/service/entityresolution/multi-strategy/types"
)

Expand Down Expand Up @@ -38,9 +37,14 @@ func (om *OutputMapper) MapResult(rawResult *types.RawResult, outputMappings []t
entityResult.Metadata[key] = value
}

// Resolve provider type for transformation dispatch. Providers stamp
// this in RawResult.Metadata; empty string is safe and falls back to
// common transformations only.
providerType, _ := rawResult.Metadata["provider_type"].(string)

// Apply output mappings
for _, mapping := range outputMappings {
if err := om.applyMapping(rawResult, entityResult, mapping); err != nil {
if err := om.applyMapping(rawResult, entityResult, mapping, providerType); err != nil {
return nil, types.WrapMultiStrategyError(
types.ErrorTypeMapping,
"failed to apply output mapping",
Expand All @@ -62,7 +66,7 @@ func (om *OutputMapper) MapResult(rawResult *types.RawResult, outputMappings []t
}

// applyMapping applies a single output mapping rule
func (om *OutputMapper) applyMapping(rawResult *types.RawResult, entityResult *types.EntityResult, mapping types.OutputMapping) error {
func (om *OutputMapper) applyMapping(rawResult *types.RawResult, entityResult *types.EntityResult, mapping types.OutputMapping, providerType string) error {
// Get source value based on provider type
sourceValue, err := om.getSourceValue(rawResult, mapping)
if err != nil {
Expand All @@ -78,8 +82,9 @@ func (om *OutputMapper) applyMapping(rawResult *types.RawResult, entityResult *t
return nil
}

// Apply transformation if specified
transformedValue, err := om.applyTransformation(sourceValue, mapping.Transformation)
// Apply transformation via the shared registry so all provider-specific
// and common transformations stay in one place.
transformedValue, err := transformation.DefaultRegistry.ApplyTransformation(sourceValue, mapping.Transformation, providerType)
if err != nil {
return types.WrapMultiStrategyError(
types.ErrorTypeMapping,
Expand Down Expand Up @@ -129,187 +134,3 @@ func (om *OutputMapper) getSourceValue(rawResult *types.RawResult, mapping types

return value, nil
}

// applyTransformation applies the specified transformation to the source value
func (om *OutputMapper) applyTransformation(value interface{}, transformation string) (interface{}, error) {
if transformation == "" {
return value, nil
}

switch strings.ToLower(transformation) {
case "array":
return om.transformToArray(value)

case "csv_to_array":
return om.transformCSVToArray(value)

case "ldap_dn_to_cn_array":
return om.transformLDAPDNToCNArray(value)

case "lowercase":
return om.transformToLowercase(value)

case "uppercase":
return om.transformToUppercase(value)

case "trim":
return om.transformTrim(value)

default:
return nil, fmt.Errorf("unknown transformation: %s", transformation)
}
}

// transformToArray ensures the value is an array
func (om *OutputMapper) transformToArray(value interface{}) (interface{}, error) {
if value == nil {
return []interface{}{}, nil
}

// If already an array, return as-is
if arr, ok := value.([]interface{}); ok {
return arr, nil
}

// If string array, convert to interface array
if strArr, ok := value.([]string); ok {
result := make([]interface{}, len(strArr))
for i, s := range strArr {
result[i] = s
}
return result, nil
}

// Otherwise, wrap single value in array
return []interface{}{value}, nil
}

// transformCSVToArray splits a CSV string into an array
func (om *OutputMapper) transformCSVToArray(value interface{}) (interface{}, error) {
if value == nil {
return []interface{}{}, nil
}

// Convert to string
str, ok := value.(string)
if !ok {
return nil, fmt.Errorf("csv_to_array transformation requires string input, got %T", value)
}

// Split by comma and trim whitespace
parts := strings.Split(str, ",")
result := make([]interface{}, 0, len(parts))

for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}

return result, nil
}

// transformLDAPDNToCNArray extracts CN values from LDAP DN strings
func (om *OutputMapper) transformLDAPDNToCNArray(value interface{}) (interface{}, error) {
if value == nil {
return []interface{}{}, nil
}

// Handle array of DNs
if arr, ok := value.([]interface{}); ok {
result := make([]interface{}, 0)
for _, item := range arr {
if itemStr, itemOk := item.(string); itemOk {
cn := om.extractCNFromDN(itemStr)
if cn != "" {
result = append(result, cn)
}
}
}
return result, nil
}

// Handle string array
if strArr, ok := value.([]string); ok {
result := make([]interface{}, 0)
for _, str := range strArr {
cn := om.extractCNFromDN(str)
if cn != "" {
result = append(result, cn)
}
}
return result, nil
}

// Handle single DN string
if str, ok := value.(string); ok {
cn := om.extractCNFromDN(str)
if cn != "" {
return []interface{}{cn}, nil
}
return []interface{}{}, nil
}

return nil, fmt.Errorf("ldap_dn_to_cn_array transformation requires string or array input, got %T", value)
}

// transformToLowercase converts string values to lowercase
func (om *OutputMapper) transformToLowercase(value interface{}) (interface{}, error) {
if value == nil {
return "", nil // Return empty string for nil values in lowercase transformation
}

if str, ok := value.(string); ok {
return strings.ToLower(str), nil
}

return nil, fmt.Errorf("lowercase transformation requires string input, got %T", value)
}

// transformToUppercase converts string values to uppercase
func (om *OutputMapper) transformToUppercase(value interface{}) (interface{}, error) {
if value == nil {
return "", nil // Return empty string for nil values in uppercase transformation
}

if str, ok := value.(string); ok {
return strings.ToUpper(str), nil
}

return nil, fmt.Errorf("uppercase transformation requires string input, got %T", value)
}

// transformTrim trims whitespace from string values
func (om *OutputMapper) transformTrim(value interface{}) (interface{}, error) {
if value == nil {
return "", nil // Return empty string for nil values in trim transformation
}

if str, ok := value.(string); ok {
return strings.TrimSpace(str), nil
}

return nil, fmt.Errorf("trim transformation requires string input, got %T", value)
}

// extractCNFromDN extracts the CN (Common Name) component from an LDAP DN
func (om *OutputMapper) extractCNFromDN(dn string) string {
// Simple CN extraction - looks for CN= at the beginning or after comma
dn = strings.TrimSpace(dn)
if dn == "" {
return ""
}

// Split DN into components
components := strings.Split(dn, ",")

for _, component := range components {
component = strings.TrimSpace(component)
if strings.HasPrefix(strings.ToUpper(component), "CN=") {
return strings.TrimSpace(component[3:])
}
}

return ""
}
141 changes: 141 additions & 0 deletions service/entityresolution/multi-strategy/output_mapper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package multistrategy

import (
"reflect"
"testing"

"github.com/opentdf/platform/service/entityresolution/multi-strategy/types"
)

func TestOutputMapper_MapResult_PostgresObject(t *testing.T) {
om := NewOutputMapper()
raw := &types.RawResult{
Data: map[string]interface{}{
"attributes": `{"department":"Engineering","clearance":"secret"}`,
},
Metadata: map[string]interface{}{
"provider_type": "sql",
},
}
mappings := []types.OutputMapping{
{SourceColumn: "attributes", ClaimName: "user_attributes", Transformation: "postgres_object"},
}

result, err := om.MapResult(raw, mappings, "entity-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := map[string]any{
"department": "Engineering",
"clearance": "secret",
}
got, ok := result.Claims["user_attributes"].(map[string]any)
if !ok {
t.Fatalf("expected map[string]any claim, got %T", result.Claims["user_attributes"])
}
if !reflect.DeepEqual(got, want) {
t.Errorf("claims mismatch: got %#v, want %#v", got, want)
}
}

func TestOutputMapper_MapResult_PostgresArray(t *testing.T) {
om := NewOutputMapper()
raw := &types.RawResult{
Data: map[string]interface{}{
"groups": "{admin,user,finance}",
},
Metadata: map[string]interface{}{
"provider_type": "sql",
},
}
mappings := []types.OutputMapping{
{SourceColumn: "groups", ClaimName: "group_memberships", Transformation: "postgres_array"},
}

result, err := om.MapResult(raw, mappings, "entity-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := []string{"admin", "user", "finance"}
got, ok := result.Claims["group_memberships"].([]string)
if !ok {
t.Fatalf("expected []string claim, got %T", result.Claims["group_memberships"])
}
if !reflect.DeepEqual(got, want) {
t.Errorf("claims mismatch: got %#v, want %#v", got, want)
}
}

func TestOutputMapper_MapResult_CommonTransformations(t *testing.T) {
om := NewOutputMapper()
raw := &types.RawResult{
Data: map[string]interface{}{
"email": " User@Example.COM ",
"roles": "admin,,analyst , reviewer",
"nickname": " bob ",
},
Metadata: map[string]interface{}{
"provider_type": "sql",
},
}
mappings := []types.OutputMapping{
{SourceColumn: "email", ClaimName: "email_lower", Transformation: "lowercase"},
{SourceColumn: "roles", ClaimName: "roles", Transformation: "csv_to_array"},
{SourceColumn: "nickname", ClaimName: "nickname", Transformation: "trim"},
}

result, err := om.MapResult(raw, mappings, "entity-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if got := result.Claims["email_lower"]; got != " user@example.com " {
t.Errorf("email_lower: got %v", got)
}
if got, ok := result.Claims["roles"].([]string); !ok || !reflect.DeepEqual(got, []string{"admin", "analyst", "reviewer"}) {
t.Errorf("roles: got %#v", result.Claims["roles"])
}
if got := result.Claims["nickname"]; got != "bob" {
t.Errorf("nickname: got %v", got)
}
}

func TestOutputMapper_MapResult_UnknownTransformationErrors(t *testing.T) {
om := NewOutputMapper()
raw := &types.RawResult{
Data: map[string]interface{}{"x": "y"},
Metadata: map[string]interface{}{"provider_type": "sql"},
}
mappings := []types.OutputMapping{
{SourceColumn: "x", ClaimName: "x", Transformation: "does_not_exist"},
}

if _, err := om.MapResult(raw, mappings, "entity-1"); err == nil {
t.Fatal("expected error for unknown transformation, got nil")
}
}

func TestOutputMapper_MapResult_MissingSourceIsSkipped(t *testing.T) {
om := NewOutputMapper()
raw := &types.RawResult{
Data: map[string]interface{}{"present": "yes"},
Metadata: map[string]interface{}{"provider_type": "sql"},
}
mappings := []types.OutputMapping{
{SourceColumn: "present", ClaimName: "present"},
{SourceColumn: "absent", ClaimName: "absent"},
}

result, err := om.MapResult(raw, mappings, "entity-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, exists := result.Claims["absent"]; exists {
t.Error("absent claim should not be set")
}
if result.Claims["present"] != "yes" {
t.Errorf("present: got %v", result.Claims["present"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ func TestClaimsMapper_GetSupportedTransformations(t *testing.T) {
"string",
"lowercase",
"uppercase",
"trim",
"jwt_extract_scope",
"jwt_normalize_groups",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ func TestLDAPMapper_GetSupportedTransformations(t *testing.T) {
"string",
"lowercase",
"uppercase",
"trim",
// LDAP-specific transformations
"ldap_dn_to_cn_array",
"ldap_dn_to_cn",
Expand Down
Loading
Loading