Skip to content
Open
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
7 changes: 5 additions & 2 deletions service/entityresolution/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,12 @@ adapter := NewMultiStrategyTestAdapter()
Provider adapters in `multistrategy_provider_contract_test.go` supply setup,
teardown, normal and reversed-strategy service construction, configuration, token
fixtures, and expected mapped fields. The suite runs the same
multi-entity chain scenarios for claims, SQL, and LDAP with both environment→subject
token-chain scenarios for claims, SQL, and LDAP with both environment→subject
and subject→environment strategy order, single and multiple tokens, collection-valued
context, and fail-closed mixed valid/invalid token batches.
context, and fail-closed mixed valid/invalid token batches. Chain resolution is
first-match-wins per the multi-strategy ERS ADR, so each chain carries exactly the
entity produced by the first matching strategy; reversing the strategy order is what
changes which entity appears.

When adding a provider, enroll one adapter to receive the existing contract scenarios.
When adding a provider-independent token-chain behavior, add it once to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import (
multistrategyv2 "github.com/opentdf/platform/service/entityresolution/multi-strategy/v2"
"github.com/opentdf/platform/service/logger"
"github.com/opentdf/platform/service/pkg/cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
)

// TestEntityChainComparison demonstrates the discrepancy between Keycloak (2 entities per chain)
// and Multi-Strategy (1 entity per chain) entity resolution systems
// TestEntityChainComparison documents the deliberate difference between Keycloak
// (2 entities per chain: ENVIRONMENT + SUBJECT) and Multi-Strategy (1 entity per chain,
// from the first matching strategy, per the multi-strategy ERS ADR).
func TestEntityChainComparison(t *testing.T) {
if testing.Short() {
t.Skip("Skipping entity chain comparison tests in short mode")
Expand Down Expand Up @@ -75,9 +78,9 @@ func TestEntityChainComparison(t *testing.T) {
})

t.Run("MultiStrategy_EntityChainLength", func(t *testing.T) {
// Create Multi-Strategy ERS service with MULTIPLE strategies like Keycloak
// Configure two strategies that both match the token to show that only the first runs
config := types.MultiStrategyConfig{
FailureStrategy: types.FailureStrategyContinue, // Continue to try all strategies
FailureStrategy: types.FailureStrategyContinue, // Only governs error handling
Providers: map[string]types.ProviderConfig{
"jwt_claims": {
Type: "claims",
Expand Down Expand Up @@ -165,29 +168,12 @@ func TestEntityChainComparison(t *testing.T) {
t.Logf(" - Entity %d: %s (Category: %s)", i+1, getEntityIdentifier(ent), ent.GetCategory())
}

// ✅ EXPECTED: Multi-strategy should now create 2+ entities like Keycloak
if actualEntityCount >= 2 {
t.Logf("✅ SUCCESS: Multi-strategy creates %d entities per chain (like Keycloak!)", actualEntityCount)

// Validate entity categories are different (ENVIRONMENT + SUBJECT)
categoryCounts := make(map[string]int)
for _, ent := range chain.GetEntities() {
categoryCounts[ent.GetCategory().String()]++
}

t.Logf(" - Entity Categories: %v", categoryCounts)

// Check we have both ENVIRONMENT and SUBJECT entities like Keycloak
if categoryCounts["CATEGORY_ENVIRONMENT"] >= 1 && categoryCounts["CATEGORY_SUBJECT"] >= 1 {
t.Logf("✅ PERFECT: Has both ENVIRONMENT and SUBJECT entities like Keycloak")
}
} else {
t.Logf("⚠️ ISSUE: Multi-strategy creates only %d entity per chain", actualEntityCount)
t.Logf("🎯 EXPECTED: Multi-strategy should create 2+ entities like Keycloak:")
t.Logf(" - Entity 1: CATEGORY_ENVIRONMENT (client from 'azp' claim)")
t.Logf(" - Entity 2: CATEGORY_SUBJECT (user from 'sub' claim)")
t.Errorf("❌ MISMATCH: Multi-strategy creates %d entities, but Keycloak creates 2 entities per chain", actualEntityCount)
}
// Both configured strategies match this token, but per the ADR the first match wins,
// so the chain holds a single ENVIRONMENT entity. failure_strategy: continue does not
// change this — it only decides whether a *failing* strategy falls through to the next.
require.Len(t, chain.GetEntities(), 1, "multi-strategy chains carry only the first matching strategy's entity")
assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, chain.GetEntities()[0].GetCategory(),
"client_environment_strategy is configured first, so it is the match that wins")
})

t.Run("CompareEntityChainStructures", func(t *testing.T) {
Expand All @@ -198,16 +184,14 @@ func TestEntityChainComparison(t *testing.T) {
t.Log(" ✅ Full JWT token processing with multiple entities")
t.Log("")
t.Log(" Multi-Strategy V2:")
t.Log(" ✅ NOW CREATES 2-entity chains (Environment + Subject) - FIXED!")
t.Log(" ✅ Proper entity categorization (ENVIRONMENT vs SUBJECT)")
t.Log(" ✅ Multiple mapping strategies per token with FailureStrategyContinue")
t.Log("")
t.Log("🎯 ACHIEVED: Multi-strategy now supports:")
t.Log(" 1. ✅ Multiple mapping strategies per token")
t.Log(" 2. ✅ Entity categorization (ENVIRONMENT vs SUBJECT)")
t.Log(" 3. ✅ Chaining multiple related entities per JWT")
t.Log(" ✅ Creates 1-entity chains from the first matching mapping strategy (ADR: first-match-wins)")
t.Log(" ✅ Proper entity categorization (ENVIRONMENT vs SUBJECT) driven by that strategy's entity_type")
t.Log(" ✅ failure_strategy governs error handling only, never how many strategies resolve")
t.Log("")
t.Log("🚀 RESULT: Multi-strategy entity chaining now matches Keycloak behavior!")
t.Log("🎯 The entity count difference is intentional: multi-entity chains are an")
t.Log(" explicit 'Future Considerations' item in the multi-strategy ERS ADR, not")
t.Log(" current behavior. Deployments needing several sources in one entity should")
t.Log(" merge them in a single strategy's output mapping.")
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,50 @@ import (
"github.com/stretchr/testify/require"
)

const (
// Test constants for entity chain resolution expectations
expectedChainEntityCount = 2
)
// ChainShape describes the chain an implementation is expected to build for one token.
// Entity count and categories are implementation-specific: Keycloak always emits an
// ENVIRONMENT (client) plus a SUBJECT (user) entity, while multi-strategy ERS is
// first-match-wins per its ADR and emits exactly one entity from the first matching
// strategy. Everything else the suite asserts is genuinely implementation-agnostic.
type ChainShape struct {
EntityCount int
EntityCategories []string
}

// keycloakChainEntityCount is the ENVIRONMENT (client) plus SUBJECT (user) pair Keycloak
// emits for every token.
const keycloakChainEntityCount = 2

// ChainContractTestSuite holds implementation-agnostic multi-entity chain validation tests
// KeycloakChainShape is the two-entity ENVIRONMENT + SUBJECT chain Keycloak produces per token.
func KeycloakChainShape() ChainShape {
return ChainShape{
EntityCount: keycloakChainEntityCount,
EntityCategories: []string{"CATEGORY_ENVIRONMENT", "CATEGORY_SUBJECT"},
}
}

// ChainContractTestSuite holds implementation-agnostic entity chain validation tests
type ChainContractTestSuite struct {
TestCases []ContractTestCase
}

// NewChainContractTestSuite creates a test suite focused on implementation-agnostic multi-entity chain validation
// NewChainContractTestSuite creates a chain contract suite for an implementation that
// builds Keycloak-shaped two-entity chains.
func NewChainContractTestSuite() *ChainContractTestSuite {
return NewChainContractTestSuiteWithShape(KeycloakChainShape())
}

// NewChainContractTestSuiteWithShape creates a chain contract suite that validates chains
// against the given implementation-specific shape.
func NewChainContractTestSuiteWithShape(shape ChainShape) *ChainContractTestSuite {
expectedChainEntityCount := shape.EntityCount
expectedChainCategories := shape.EntityCategories

return &ChainContractTestSuite{
TestCases: []ContractTestCase{
{
Name: "CreateMultiEntityChainFromSingleToken",
Description: "Should create entity chain with multiple entities and proper categorization",
Name: "CreateEntityChainFromSingleToken",
Description: "Should create an entity chain matching the implementation chain shape with proper categorization",
Input: ContractInput{
Entities: []*entity.Entity{},
Tokens: []*entity.Token{
Expand All @@ -44,17 +71,17 @@ func NewChainContractTestSuite() *ChainContractTestSuite {
ChainValidation: []EntityChainValidationRule{
{
EphemeralID: "chain-token-1",
EntityCount: expectedChainEntityCount, // Both Keycloak and Multi-Strategy create 2 entities per token
EntityTypes: []string{}, // Implementation-agnostic: don't specify entity types
EntityCategories: []string{"CATEGORY_ENVIRONMENT", "CATEGORY_SUBJECT"}, // Both must create these categories
RequireConsistentOrdering: false, // Allow flexible ordering between implementations
EntityCount: expectedChainEntityCount,
EntityTypes: []string{}, // Implementation-agnostic: don't specify entity types
EntityCategories: expectedChainCategories,
RequireConsistentOrdering: false, // Allow flexible ordering between implementations
},
},
},
},
{
Name: "CreateMultiEntityChainsFromMultipleTokens",
Description: "Should create multiple entity chains with consistent multi-entity behavior",
Name: "CreateEntityChainsFromMultipleTokens",
Description: "Should create one entity chain per token with consistent shape",
Input: ContractInput{
Entities: []*entity.Entity{},
Tokens: []*entity.Token{
Expand All @@ -70,24 +97,24 @@ func NewChainContractTestSuite() *ChainContractTestSuite {
ChainValidation: []EntityChainValidationRule{
{
EphemeralID: "chain-token-1",
EntityCount: expectedChainEntityCount, // Both implementations create 2 entities per token
EntityTypes: []string{}, // Implementation-agnostic
EntityCategories: []string{"CATEGORY_ENVIRONMENT", "CATEGORY_SUBJECT"},
EntityCount: expectedChainEntityCount,
EntityTypes: []string{}, // Implementation-agnostic
EntityCategories: expectedChainCategories,
RequireConsistentOrdering: false,
},
{
EphemeralID: "chain-token-2",
EntityCount: expectedChainEntityCount, // Consistent behavior across tokens
EntityTypes: []string{}, // Implementation-agnostic
EntityCategories: []string{"CATEGORY_ENVIRONMENT", "CATEGORY_SUBJECT"},
EntityCount: expectedChainEntityCount,
EntityTypes: []string{}, // Implementation-agnostic
EntityCategories: expectedChainCategories,
RequireConsistentOrdering: false,
},
},
},
},
{
Name: "ValidateEntityChainCategoryDifferentiation",
Description: "Should create entity chains with distinct ENVIRONMENT and SUBJECT categories",
Description: "Should create entity chains carrying the expected entity categories",
Input: ContractInput{
Entities: []*entity.Entity{},
Tokens: []*entity.Token{
Expand All @@ -102,17 +129,17 @@ func NewChainContractTestSuite() *ChainContractTestSuite {
ChainValidation: []EntityChainValidationRule{
{
EphemeralID: "category-test-token",
EntityCount: expectedChainEntityCount, // Both implementations create multiple entities
EntityTypes: []string{}, // Implementation-agnostic: entity types vary by implementation
EntityCategories: []string{"CATEGORY_ENVIRONMENT", "CATEGORY_SUBJECT"}, // Contract: both categories must exist
RequireConsistentOrdering: false, // Allow implementation flexibility
EntityCount: expectedChainEntityCount,
EntityTypes: []string{}, // Implementation-agnostic: entity types vary by implementation
EntityCategories: expectedChainCategories,
RequireConsistentOrdering: false, // Allow implementation flexibility
},
},
},
},
{
Name: "ValidateMultiEntityChainConsistency",
Description: "Should create consistent multi-entity chains across multiple invocations",
Name: "ValidateEntityChainConsistency",
Description: "Should create consistent entity chains across multiple invocations",
Input: ContractInput{
Entities: []*entity.Entity{},
Tokens: []*entity.Token{
Expand All @@ -127,9 +154,9 @@ func NewChainContractTestSuite() *ChainContractTestSuite {
ChainValidation: []EntityChainValidationRule{
{
EphemeralID: "consistency-token",
EntityCount: expectedChainEntityCount, // Consistent entity count across implementations
EntityTypes: []string{}, // Implementation-specific entity types allowed
EntityCategories: []string{"CATEGORY_ENVIRONMENT", "CATEGORY_SUBJECT"},
EntityCount: expectedChainEntityCount,
EntityTypes: []string{}, // Implementation-specific entity types allowed
EntityCategories: expectedChainCategories,
RequireConsistentOrdering: false, // Behavioral contract, not implementation details
},
},
Expand All @@ -139,7 +166,7 @@ func NewChainContractTestSuite() *ChainContractTestSuite {
}
}

// RunChainContractTests executes multi-entity chain tests against an ERS implementation
// RunChainContractTests executes entity chain tests against an ERS implementation
func (suite *ChainContractTestSuite) RunChainContractTests(t *testing.T, implementation ERSImplementation, _ string) {
for _, testCase := range suite.TestCases {
t.Run(testCase.Name, func(t *testing.T) {
Expand All @@ -148,7 +175,7 @@ func (suite *ChainContractTestSuite) RunChainContractTests(t *testing.T, impleme
}
}

// runSingleChainTest executes a single multi-entity chain test
// runSingleChainTest executes a single entity chain test
func (suite *ChainContractTestSuite) runSingleChainTest(t *testing.T, implementation ERSImplementation, testCase ContractTestCase) {
// Test CreateEntityChainsFromTokens if tokens are provided
if len(testCase.Input.Tokens) == 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,37 @@ type ResolvedTokenChainEntityExpectation struct {
}

// ResolvedTokenChainExpectation describes the final mapped context expected for one token.
// Entities are listed in mapping-strategy order; because chain resolution is first-match-wins
// the suite only ever expects one of them in a given chain.
type ResolvedTokenChainExpectation struct {
Token *entity.Token
Entities []ResolvedTokenChainEntityExpectation
}

// firstMatch narrows the expectation to the entity produced by the first matching strategy.
func (e ResolvedTokenChainExpectation) firstMatch() ResolvedTokenChainExpectation {
e.Entities = e.Entities[:1]
return e
}

// lastMatch narrows the expectation to the entity produced by the last configured strategy,
// which becomes the first match once the strategy list is reversed.
func (e ResolvedTokenChainExpectation) lastMatch() ResolvedTokenChainExpectation {
e.Entities = e.Entities[len(e.Entities)-1:]
return e
}

func narrowExpectations(
expectations []ResolvedTokenChainExpectation,
narrow func(ResolvedTokenChainExpectation) ResolvedTokenChainExpectation,
) []ResolvedTokenChainExpectation {
narrowed := make([]ResolvedTokenChainExpectation, 0, len(expectations))
for _, expectation := range expectations {
narrowed = append(narrowed, narrow(expectation))
}
return narrowed
}

// ResolvedTokenChainAdapter enrolls an ERS/provider configuration in the shared
// token-chain contract. Implementations provide setup and fixtures; the suite owns behavior.
type ResolvedTokenChainAdapter interface {
Expand Down Expand Up @@ -60,19 +86,24 @@ func (suite *ResolvedTokenChainContractSuite) RunWithAdapter(t *testing.T, adapt
expectations := adapter.ResolvedTokenChainExpectations(dataSet)
require.NotEmpty(t, expectations)

t.Run(adapter.GetScopeName()+"_EnvironmentThenSubjectPreservesMultiEntityMappedContext", func(t *testing.T) {
suite.assertResolvedTokenChains(t, implementation, expectations[:1])
// Chain resolution is first-match-wins, so the environment strategy (configured first)
// is the only one that runs and the resolved chain carries just its mapped context.
t.Run(adapter.GetScopeName()+"_EnvironmentThenSubjectPreservesFirstMatchMappedContext", func(t *testing.T) {
suite.assertResolvedTokenChains(t, implementation, narrowExpectations(expectations[:1], ResolvedTokenChainExpectation.firstMatch))
})

// Reversing the strategy list makes the subject strategy the first match, which must be
// the only entity in the chain. This is what proves ordering — not failure strategy —
// decides which strategy resolves the token.
reversedImplementation, err := adapter.CreateERSServiceWithReversedStrategies(ctx)
require.NoError(t, err)
t.Run(adapter.GetScopeName()+"_SubjectThenEnvironmentPreservesMultiEntityMappedContext", func(t *testing.T) {
suite.assertResolvedTokenChains(t, reversedImplementation, expectations[:1])
t.Run(adapter.GetScopeName()+"_SubjectThenEnvironmentPreservesFirstMatchMappedContext", func(t *testing.T) {
suite.assertResolvedTokenChains(t, reversedImplementation, narrowExpectations(expectations[:1], ResolvedTokenChainExpectation.lastMatch))
})

if len(expectations) > 1 {
t.Run(adapter.GetScopeName()+"_MultipleTokensPreserveMultiEntityMappedContext", func(t *testing.T) {
suite.assertResolvedTokenChains(t, implementation, expectations)
t.Run(adapter.GetScopeName()+"_MultipleTokensPreserveFirstMatchMappedContext", func(t *testing.T) {
suite.assertResolvedTokenChains(t, implementation, narrowExpectations(expectations, ResolvedTokenChainExpectation.firstMatch))
})
}

Expand Down
Loading
Loading