diff --git a/service/entityresolution/integration/internal/contract_tests.go b/service/entityresolution/integration/internal/contract_tests.go index 8141ababe6..eb338ba577 100644 --- a/service/entityresolution/integration/internal/contract_tests.go +++ b/service/entityresolution/integration/internal/contract_tests.go @@ -11,6 +11,7 @@ import ( entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) const ( @@ -90,11 +91,12 @@ type EntityValidationRule struct { // EntityChainValidationRule defines how to validate a returned entity chain type EntityChainValidationRule struct { - EphemeralID string // Expected ephemeral ID - EntityCount int // Expected number of entities in the chain - EntityTypes []string // Expected entity types in order - EntityCategories []string // Expected entity categories in order (CATEGORY_ENVIRONMENT, CATEGORY_SUBJECT) - RequireConsistentOrdering bool // Whether entity order must be consistent across implementations + EphemeralID string // Expected ephemeral ID + EntityCount int // Expected number of entities in the chain + EntityTypes []string // Expected entity types in order + EntityCategories []string // Expected entity categories in order (CATEGORY_ENVIRONMENT, CATEGORY_SUBJECT) + EntityRequiredFields []map[string]interface{} // Required claims fields for each entity, by index + RequireConsistentOrdering bool // Whether entity order must be consistent across implementations } // ContractTestSuite holds all the contract tests for ERS implementations @@ -521,9 +523,10 @@ func (suite *ContractTestSuite) validateContractChain(t *testing.T, chains []*en entities := matchingChain.GetEntities() assert.Len(t, entities, validationRule.EntityCount, "Unexpected number of entities in chain") - // Validate entity types and categories + // Validate entity types, categories, and preserved claims context. suite.validateChainEntityTypes(t, entities, validationRule) suite.validateChainEntityCategories(t, entities, validationRule) + suite.validateChainEntityRequiredFields(t, entities, validationRule) } // validateChainEntityTypes validates entity types in chain @@ -555,6 +558,30 @@ func (suite *ContractTestSuite) validateFlexibleEntityType(t *testing.T, entitie assert.Fail(t, fmt.Sprintf("Expected entity type %s not found in chain", expectedType)) } +func (suite *ContractTestSuite) validateChainEntityRequiredFields(t *testing.T, entities []*entity.Entity, validationRule EntityChainValidationRule) { + for idx, requiredFields := range validationRule.EntityRequiredFields { + if idx >= len(entities) { + t.Errorf("Required-fields rule index %d out of bounds (got %d entities)", idx, len(entities)) + continue + } + if len(requiredFields) == 0 { + continue + } + + claims := entities[idx].GetClaims() + if claims == nil { + t.Errorf("Entity at index %d does not contain claims", idx) + continue + } + var claimsStruct structpb.Struct + if err := claims.UnmarshalTo(&claimsStruct); err != nil { + t.Errorf("Failed to unpack claims for entity at index %d: %v", idx, err) + continue + } + suite.validateRequiredFields(t, claimsStruct.AsMap(), requiredFields) + } +} + // validateChainEntityCategories validates entity categories in chain func (suite *ContractTestSuite) validateChainEntityCategories(t *testing.T, entities []*entity.Entity, validationRule EntityChainValidationRule) { for i, expectedCategory := range validationRule.EntityCategories { diff --git a/service/entityresolution/integration/multistrategy_comprehensive_test.go b/service/entityresolution/integration/multistrategy_comprehensive_test.go index 929914a559..8f45510cf1 100644 --- a/service/entityresolution/integration/multistrategy_comprehensive_test.go +++ b/service/entityresolution/integration/multistrategy_comprehensive_test.go @@ -20,6 +20,7 @@ import ( "github.com/opentdf/platform/service/logger" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" + "google.golang.org/protobuf/types/known/structpb" _ "github.com/lib/pq" // PostgreSQL driver _ "github.com/mattn/go-sqlite3" @@ -102,8 +103,12 @@ func TestMultiStrategy_ClaimsOnly(t *testing.T) { } entity := chain.GetEntities()[0] - if entity.GetUserName() != "testuser" { - t.Errorf("Expected username 'testuser', got '%s'", entity.GetUserName()) + claims := chainEntityClaimsMap(t, entity) + if got := claims["username"]; got != "testuser" { + t.Errorf("Expected username 'testuser', got '%v'", got) + } + if got := claims["email_address"]; got != "test@example.com" { + t.Errorf("Expected email_address 'test@example.com', got '%v'", got) } t.Logf("✅ Claims-only multi-strategy test passed: Created %d entities", len(chain.GetEntities())) @@ -267,6 +272,14 @@ func TestMultiStrategy_SQLOnly(t *testing.T) { t.Fatal("Expected at least one entity in chain") } + claims := chainEntityClaimsMap(t, chain.GetEntities()[0]) + if got := claims["username"]; got != "alice" { + t.Fatalf("Expected username 'alice', got %v", got) + } + if got := claims["display_name"]; got != "Alice Test" { + t.Fatalf("Expected display_name 'Alice Test', got %v", got) + } + t.Logf("✅ SQL-only multi-strategy test passed: Created %d entities", len(chain.GetEntities())) } @@ -674,8 +687,9 @@ func TestMultiStrategy_MultiProviderFailover(t *testing.T) { } entity := chain.GetEntities()[0] - if entity.GetUserName() != "failover-user" { - t.Errorf("Expected username 'failover-user', got '%s'", entity.GetUserName()) + claims := chainEntityClaimsMap(t, entity) + if got := claims["username"]; got != "failover-user" { + t.Errorf("Expected username 'failover-user', got '%v'", got) } t.Logf("✅ Multi-provider failover test passed: Failed over to claims provider and created %d entities", len(chain.GetEntities())) @@ -775,8 +789,9 @@ func TestMultiStrategy_MultiProviderEarlySuccess(t *testing.T) { } entity := chain.GetEntities()[0] - if entity.GetUserName() != "early-user" { - t.Errorf("Expected username 'early-user', got '%s'", entity.GetUserName()) + claims := chainEntityClaimsMap(t, entity) + if got := claims["username"]; got != "early-user" { + t.Errorf("Expected username 'early-user', got '%v'", got) } // Should be fast because it short-circuited on first success @@ -888,13 +903,14 @@ func TestMultiStrategy_EntityChainCreation(t *testing.T) { totalEntities += len(chain.GetEntities()) entity := chain.GetEntities()[0] + claims := chainEntityClaimsMap(t, entity) expectedUsername := fmt.Sprintf("chain-user-%d", i+1) - if entity.GetUserName() != expectedUsername { - t.Errorf("Chain %d: Expected username '%s', got '%s'", i, expectedUsername, entity.GetUserName()) + if got := claims["username"]; got != expectedUsername { + t.Errorf("Chain %d: Expected username '%s', got '%v'", i, expectedUsername, got) } - t.Logf("Chain %d: EphemeralId=%s, Username=%s, Entities=%d", - i, chain.GetEphemeralId(), entity.GetUserName(), len(chain.GetEntities())) + t.Logf("Chain %d: EphemeralId=%s, Username=%v, Entities=%d", + i, chain.GetEphemeralId(), claims["username"], len(chain.GetEntities())) } t.Logf("✅ Entity chain creation test passed: Created %d chains with %d total entities", @@ -903,6 +919,20 @@ func TestMultiStrategy_EntityChainCreation(t *testing.T) { // Helper functions +func chainEntityClaimsMap(t *testing.T, ent *entity.Entity) map[string]interface{} { + t.Helper() + claims := ent.GetClaims() + if claims == nil { + t.Fatalf("chain entity %q carries no claims; entity type is %T", ent.GetEphemeralId(), ent.GetEntityType()) + } + + var claimsStruct structpb.Struct + if err := claims.UnmarshalTo(&claimsStruct); err != nil { + t.Fatalf("failed to decode chain entity claims: %v", err) + } + return claimsStruct.AsMap() +} + func createComprehensiveTestJWT(sub, email string) string { // This creates a properly formatted JWT for testing purposes (not cryptographically signed) // Header: {"alg":"HS256","typ":"JWT"} @@ -992,7 +1022,16 @@ func startSeededLDAPContainer(ctx context.Context, t *testing.T) (testcontainers FileMode: 0o644, }, }, - WaitingFor: wait.ForListeningPort("389/tcp").WithStartupTimeout(60 * time.Second), + WaitingFor: wait.ForAll( + wait.ForListeningPort("389/tcp"), + wait.ForExec([]string{ + "sh", "-c", + "ldapsearch -x -H ldap://localhost:389 " + + "-D cn=admin,dc=opentdf,dc=test -w admin123 " + + "-b ou=users,dc=opentdf,dc=test '(uid=alice)' dn " + + "| grep -q '^dn: uid=alice,ou=users,dc=opentdf,dc=test$'", + }), + ).WithDeadline(60 * time.Second), } ldapContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ diff --git a/service/entityresolution/integration/multistrategy_test.go b/service/entityresolution/integration/multistrategy_test.go index 9cfab61615..9bfc8a22cd 100644 --- a/service/entityresolution/integration/multistrategy_test.go +++ b/service/entityresolution/integration/multistrategy_test.go @@ -445,9 +445,16 @@ func TestMultiStrategyEntityResolutionV2(t *testing.T) { Expected: internal.ContractExpected{ ChainValidation: []internal.EntityChainValidationRule{ { - EphemeralID: "test-token-1", - EntityCount: 3, // Multi-strategy with FailureStrategyContinue creates multiple entities from all matching strategies - EntityTypes: []string{"username", "username", "username"}, // All strategies create username entities + EphemeralID: "test-token-1", + EntityCount: 3, + EntityTypes: []string{"claims", "claims", "claims"}, + EntityCategories: []string{"CATEGORY_SUBJECT", "CATEGORY_SUBJECT", "CATEGORY_SUBJECT"}, + EntityRequiredFields: []map[string]interface{}{ + {"username": "user123", "email": "user@example.com"}, + {"client_id": "external-client"}, + {"username": "user123", "email": "user@example.com"}, + }, + RequireConsistentOrdering: true, }, }, }, diff --git a/service/entityresolution/integration/multistrategy_v2_serialization_test.go b/service/entityresolution/integration/multistrategy_v2_serialization_test.go index 2912f38caa..73c5d39fe5 100644 --- a/service/entityresolution/integration/multistrategy_v2_serialization_test.go +++ b/service/entityresolution/integration/multistrategy_v2_serialization_test.go @@ -23,19 +23,10 @@ import ( // These tests exercise the real ERSV2 handler (no HTTP layer) with real // providers, mirroring the JustInTimePDP call patterns from production. -// serializationTestConfig builds a config with a claims provider and -// strategies covering the shapes needed to exercise the two v2 call paths: -// - user_strategy: matches on `sub` (JWT-cased, as CreateEntityChainsFromTokens -// sees it on the first call). -// - user_mirror_strategy: matches on `userName` (proto-cased, as the default -// ResolveEntities path derives it from an Entity_UserName payload — the -// second call in the JustInTimePDP handshake). -// - client_strategy: environment entity matched via `azp`. -// -// The mirror strategy is what the spec's "Related concern" section flags as -// commonly missing in real configs — including it here means the two-call -// handshake test actually hits the success path on call 2, which is where -// the []string -> structpb bug fires. +// serializationTestConfig builds a claims-provider configuration for both +// direct ResolveEntities calls and token-created chains. Token chains preserve +// the phase-1 mapped output and therefore do not need a mirror strategy for a +// second resolution pass. func serializationTestConfig() types.MultiStrategyConfig { return types.MultiStrategyConfig{ FailureStrategy: types.FailureStrategyContinue, @@ -60,19 +51,6 @@ func serializationTestConfig() types.MultiStrategyConfig { {SourceClaim: "email", ClaimName: "email_address"}, }, }, - { - Name: "user_mirror_strategy", - Provider: "jwt_claims", - EntityType: types.EntityTypeSubject, - Conditions: types.StrategyConditions{ - JWTClaims: []types.JWTClaimCondition{ - {Claim: "userName", Operator: "exists"}, - }, - }, - OutputMapping: []types.OutputMapping{ - {SourceClaim: "userName", ClaimName: "username"}, - }, - }, { Name: "client_strategy", Provider: "jwt_claims", @@ -178,14 +156,11 @@ func TestIntegration_ResolveEntities_ReturnsPopulatedRepresentation(t *testing.T } } -// TestIntegration_TwoCallHandshake is spec integration test 2: exact -// reproduction of the JustInTimePDP.resolveEntitiesFromToken production -// flow. First call CreateEntityChainsFromTokens with a signed JWT that -// matches a configured strategy; take the returned chain, feed each -// entity into a subsequent ResolveEntities call. Every input entity must -// produce a non-empty representation. Today this silently drops entities -// and the caller (KAS) can't tell. -func TestIntegration_TwoCallHandshake(t *testing.T) { +// TestIntegration_TokenChainPreservesResolvedClaims is spec integration test 2: +// after CreateEntityChainsFromTokens, the returned chain should already contain +// the resolved claims needed for downstream authz. This avoids having to route +// the chain back through ResolveEntities from a lossy identity projection. +func TestIntegration_TokenChainPreservesResolvedClaims(t *testing.T) { if testing.Short() { t.Skip("Skipping multi-strategy integration tests in short mode") } @@ -198,14 +173,13 @@ func TestIntegration_TwoCallHandshake(t *testing.T) { jwt := createMockJWTForUser("alice", "alice@example.com") chainReq := connect.NewRequest(&entityresolutionV2.CreateEntityChainsFromTokensRequest{ - Tokens: []*entity.Token{ - {EphemeralId: "token-alice", Jwt: jwt}, - }, + Tokens: []*entity.Token{{EphemeralId: "token-alice", Jwt: jwt}}, }) chainResp, err := ers.CreateEntityChainsFromTokens(t.Context(), chainReq) if err != nil { t.Fatalf("CreateEntityChainsFromTokens returned error: %v", err) } + chains := chainResp.Msg.GetEntityChains() if len(chains) != 1 { t.Fatalf("EntityChains length = %d, want 1", len(chains)) @@ -215,50 +189,22 @@ func TestIntegration_TwoCallHandshake(t *testing.T) { t.Fatalf("chain contains no entities") } - // Second call: feed each resolved chain entity into ResolveEntities, - // exactly as JustInTimePDP.resolveEntitiesFromToken does. Chain - // entities are typed (UserName / EmailAddress / ClientId), so the - // handler takes the default (proto-marshalled) path — the derived - // claimsMap contains proto-cased names like "userName", which is - // why the config includes a mirror strategy keyed on "userName". - resolveReq := connect.NewRequest(&entityresolutionV2.ResolveEntitiesRequest{ - Entities: chainEntities, - }) - // The claims provider reads from ctx. Populate it with both the - // original JWT claims AND the proto-cased entity claim so the - // mirror strategy actually resolves (otherwise the second call would - // fail with "no matching strategy" and never exercise the success - // path where the []string bug lives). - resolveResp, err := ers.ResolveEntities(ctxWithClaims(t, types.JWTClaims{ - "sub": "alice", - "email": "alice@example.com", - "userName": "alice", - }), resolveReq) - if err != nil { - t.Fatalf("ResolveEntities returned error: %v", err) - } + for i, chained := range chainEntities { + claims := chained.GetClaims() + if claims == nil { + t.Fatalf("entity %d should preserve resolved claims in the chain, got type %T", i, chained.GetEntityType()) + } - reps := resolveResp.Msg.GetEntityRepresentations() - if len(reps) != len(chainEntities) { - t.Fatalf("EntityRepresentations length = %d, want %d (one per chain entity — missing entries mean the handler silently dropped them via structpb.NewStruct failure)", len(reps), len(chainEntities)) - } - for i, rep := range reps { - props := rep.GetAdditionalProps() - if len(props) == 0 { - t.Errorf("rep[%d] AdditionalProps empty; entity %q was not resolved", i, rep.GetOriginalId()) - continue + var claimsStruct structpb.Struct + if err := claims.UnmarshalTo(&claimsStruct); err != nil { + t.Fatalf("entity %d claims unmarshal failed: %v", i, err) } - // The second call should have SUCCEEDED, not returned an error - // struct. If we see an error field, either the mirror strategy - // isn't wired up or the []string bug silently dropped a real - // success and something else populated an error. - if _, hasError := props[0].GetFields()["error"]; hasError { - t.Errorf("rep[%d] entity %q carries error struct instead of resolved claims: %v", i, rep.GetOriginalId(), props[0].GetFields()) + asMap := claimsStruct.AsMap() + if got := asMap["username"]; got != "alice" { + t.Fatalf("entity %d username = %v, want alice", i, got) } - // metadata_attempted_strategies must survive serialization — this - // is the field that trips the []string bug. - if _, ok := props[0].GetFields()["metadata_attempted_strategies"]; !ok { - t.Errorf("rep[%d] entity %q missing metadata_attempted_strategies — likely dropped by the structpb failure", i, rep.GetOriginalId()) + if got := asMap["email_address"]; got != "alice@example.com" { + t.Fatalf("entity %d email_address = %v, want alice@example.com", i, got) } } } diff --git a/service/entityresolution/multi-strategy/service.go b/service/entityresolution/multi-strategy/service.go index 0cd91048f2..4bfb0461fc 100644 --- a/service/entityresolution/multi-strategy/service.go +++ b/service/entityresolution/multi-strategy/service.go @@ -112,22 +112,7 @@ func (s *Service) ResolveEntity(ctx context.Context, entityID string, claimsMap } // Success - add strategy metadata and return result - result.Metadata["strategy_name"] = strategy.Name - result.Metadata["strategy_provider"] = strategy.Provider - result.Metadata["entity_type"] = strategy.EntityType - result.Metadata["failure_strategy"] = failureStrategy - // Coerce []string -> []interface{} so structpb.NewValue (called by - // the v2 ResolveEntities handler when it serializes metadata into - // EntityRepresentation.AdditionalProps) can encode it. structpb's - // NewValue accepts string|float64|bool|nil|map|[]interface{} only - - // a raw []string trips "proto: invalid type: []string" and the - // resolved entity is silently dropped via `continue` in the loop. - attemptedAny := make([]interface{}, len(attemptedStrategies)) - for i, strat := range attemptedStrategies { - attemptedAny[i] = strat - } - result.Metadata["attempted_strategies"] = attemptedAny - + s.applyStrategyMetadata(result, strategy, attemptedStrategies) return result, nil } @@ -145,6 +130,19 @@ func (s *Service) ResolveEntity(ctx context.Context, entityID string, claimsMap ) } +// ResolveEntityWithStrategy executes one already-selected strategy without re-running +// strategy matching. This is used when building token chains that include one entity per +// successful matching strategy. +func (s *Service) ResolveEntityWithStrategy(ctx context.Context, entityID string, claimsMap types.JWTClaims, strategy *types.MappingStrategy) (*types.EntityResult, error) { + result, err := s.executeStrategy(ctx, entityID, claimsMap, strategy) + if err != nil { + return nil, err + } + + s.applyStrategyMetadata(result, strategy, []string{strategy.Name}) + return result, nil +} + // HealthCheck performs health checks on all providers func (s *Service) HealthCheck(ctx context.Context) error { providers := s.providerRegistry.GetAllProviders() @@ -198,6 +196,26 @@ func (s *Service) GetProviders() map[string]string { return result } +// applyStrategyMetadata records selected-strategy provenance on the result. The attempted +// strategies are converted to []interface{} because structpb.NewValue cannot encode []string. +func (s *Service) applyStrategyMetadata(result *types.EntityResult, strategy *types.MappingStrategy, attemptedStrategies []string) { + failureStrategy := s.config.FailureStrategy + if failureStrategy == "" { + failureStrategy = types.FailureStrategyFailFast + } + + attemptedAny := make([]interface{}, len(attemptedStrategies)) + for i, attempted := range attemptedStrategies { + attemptedAny[i] = attempted + } + + result.Metadata["strategy_name"] = strategy.Name + result.Metadata["strategy_provider"] = strategy.Provider + result.Metadata["entity_type"] = strategy.EntityType + result.Metadata["failure_strategy"] = failureStrategy + result.Metadata["attempted_strategies"] = attemptedAny +} + // executeStrategy executes a single mapping strategy func (s *Service) executeStrategy(ctx context.Context, entityID string, jwtClaims types.JWTClaims, strategy *types.MappingStrategy) (*types.EntityResult, error) { // Get provider for this strategy diff --git a/service/entityresolution/multi-strategy/service_test.go b/service/entityresolution/multi-strategy/service_test.go index 4e0db693b2..5427572b10 100644 --- a/service/entityresolution/multi-strategy/service_test.go +++ b/service/entityresolution/multi-strategy/service_test.go @@ -10,6 +10,63 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) +func TestResolveEntityWithStrategyExecutesSelectedStrategy(t *testing.T) { + config := types.MultiStrategyConfig{ + Providers: map[string]types.ProviderConfig{ + "jwt": {Type: "claims", Connection: map[string]interface{}{}}, + }, + MappingStrategies: []types.MappingStrategy{ + { + Name: "username_strategy", + Provider: "jwt", + EntityType: types.EntityTypeSubject, + OutputMapping: []types.OutputMapping{ + {SourceClaim: "sub", ClaimName: "username"}, + }, + }, + { + Name: "client_strategy", + Provider: "jwt", + EntityType: types.EntityTypeEnvironment, + OutputMapping: []types.OutputMapping{ + {SourceClaim: "azp", ClaimName: "client_id"}, + }, + }, + }, + } + + service, err := NewService(t.Context(), config, logger.CreateTestLogger()) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + defer service.Close() + + claims := types.JWTClaims{"sub": "alice", "azp": "opentdf-sdk"} + ctx := context.WithValue(t.Context(), types.JWTClaimsContextKey, claims) + selected := &config.MappingStrategies[1] + + result, err := service.ResolveEntityWithStrategy(ctx, "token-alice", claims, selected) + if err != nil { + t.Fatalf("ResolveEntityWithStrategy() error = %v", err) + } + if _, exists := result.Claims["username"]; exists { + t.Fatalf("selected client strategy unexpectedly returned username: %v", result.Claims) + } + if got := result.Claims["client_id"]; got != "opentdf-sdk" { + t.Fatalf("expected client_id opentdf-sdk, got %v", got) + } + if got := result.Metadata["strategy_name"]; got != "client_strategy" { + t.Fatalf("expected strategy_name client_strategy, got %v", got) + } + attempted, ok := result.Metadata["attempted_strategies"].([]interface{}) + if !ok { + t.Fatalf("attempted_strategies must be []interface{} for structpb encoding, got %T", result.Metadata["attempted_strategies"]) + } + if len(attempted) != 1 || attempted[0] != "client_strategy" { + t.Fatalf("expected attempted_strategies [client_strategy], got %v", attempted) + } +} + func TestMultiStrategyService_JWT_Claims_Provider(t *testing.T) { // Test configuration with JWT claims provider config := types.MultiStrategyConfig{ diff --git a/service/entityresolution/multi-strategy/v2/registration.go b/service/entityresolution/multi-strategy/v2/registration.go index 4d04f77888..594e7c0a57 100644 --- a/service/entityresolution/multi-strategy/v2/registration.go +++ b/service/entityresolution/multi-strategy/v2/registration.go @@ -20,6 +20,7 @@ import ( "github.com/opentdf/platform/service/pkg/serviceregistry" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" ) @@ -243,8 +244,8 @@ func (ers *ERSV2) createEntityChainFromSingleTokenV2(ctx context.Context, token // Put JWT claims into context for providers to access ctxWithClaims := context.WithValue(ctx, types.JWTClaimsContextKey, jwtClaims) - // Resolve entity using this strategy - entityResult, err := ers.service.ResolveEntity(ctxWithClaims, token.GetEphemeralId(), jwtClaims) + // Resolve entity using this already-selected strategy. + entityResult, err := ers.service.ResolveEntityWithStrategy(ctxWithClaims, token.GetEphemeralId(), jwtClaims, strategy) if err != nil { lastError = err ers.logger.WarnContext(ctx, "strategy failed for token", @@ -271,8 +272,20 @@ func (ers *ERSV2) createEntityChainFromSingleTokenV2(ctx context.Context, token continue } - // Success! Create entity from result - entityV2 := ers.createEntityFromResultV2(ctx, entityResult, strategy, token.GetEphemeralId()) + // Success! Create entity from result. + // If this serialization step fails after strategy resolution succeeded, fail the + // token chain immediately regardless of failure strategy to avoid partial chains. + entityV2, err := ers.createEntityForTokenChain( + ctx, + entityResult, + strategy, + token.GetEphemeralId(), + failureStrategy, + attemptedStrategies, + ) + if err != nil { + return nil, err + } entities = append(entities, entityV2) ers.logger.DebugContext(ctx, "successfully resolved entity for token", @@ -311,81 +324,96 @@ func (ers *ERSV2) createEntityChainFromSingleTokenV2(ctx context.Context, token }, nil } -// createEntityFromResultV2 converts a multi-strategy EntityResult to a v2 entity.Entity -func (ers *ERSV2) createEntityFromResultV2(ctx context.Context, result *types.EntityResult, strategy *types.MappingStrategy, tokenID string) *entity.Entity { - // Determine entity category based on strategy configuration - category := entity.Entity_CATEGORY_SUBJECT // Default - if strategy.EntityType == types.EntityTypeEnvironment { - category = entity.Entity_CATEGORY_ENVIRONMENT +// createEntityForTokenChain serializes a successfully resolved strategy result. Serialization +// failures always fail closed because omitting the result would create a partial identity chain. +func (ers *ERSV2) createEntityForTokenChain( + ctx context.Context, + result *types.EntityResult, + strategy *types.MappingStrategy, + tokenID string, + failureStrategy string, + attemptedStrategies []string, +) (*entity.Entity, error) { + entityV2, err := ers.createEntityFromResultV2(ctx, result, strategy, tokenID) + if err == nil { + return entityV2, nil } - // Create entity based on available claims - // Priority: username > email > client_id > subject - var entityV2 *entity.Entity - - if username, exists := result.Claims["username"]; exists { - if usernameStr, ok := username.(string); ok && usernameStr != "" { - entityV2 = &entity.Entity{ - EntityType: &entity.Entity_UserName{UserName: usernameStr}, - Category: category, - } - } - } + ers.logger.WarnContext(ctx, "failed to serialize resolved entity for token", + slog.String("token_id", tokenID), + slog.String("strategy", strategy.Name), + slog.String("error", err.Error())) + + return nil, types.WrapMultiStrategyError( + types.ErrorTypeMapping, + "resolved entity serialization failed after successful strategy resolution", + err, + map[string]interface{}{ + "token_id": tokenID, + "strategy": strategy.Name, + "failure_strategy": failureStrategy, + "attempted_strategies": attemptedStrategies, + }, + ) +} - if entityV2 == nil { - if email, exists := result.Claims["email_address"]; exists { - if emailStr, ok := email.(string); ok && emailStr != "" { - entityV2 = &entity.Entity{ - EntityType: &entity.Entity_EmailAddress{EmailAddress: emailStr}, - Category: category, - } - } - } +// createEntityFromResultV2 converts a multi-strategy EntityResult to a v2 entity.Entity. +// +// For token-derived entity chains, preserve the resolved claims directly in the chain so +// downstream authz can consume the resolved subject/environment context without rehydrating +// through ERS and re-routing strategy selection from a lossy identity projection. +// +// EntityResult.Metadata is intentionally omitted. It describes ERS resolution mechanics and +// provenance, not the subject or environment entity. Including it in this claims payload would +// expose it to subject mappings and couple portable ABAC policy to multi-strategy provider names, +// strategy ordering, and other deployment-specific ERS structure. Resolution metadata belongs +// in observability or a dedicated out-of-band metadata channel, not in policy input. +func (ers *ERSV2) createEntityFromResultV2(_ context.Context, result *types.EntityResult, strategy *types.MappingStrategy, tokenID string) (*entity.Entity, error) { + category := entity.Entity_CATEGORY_SUBJECT + if strategy.EntityType == types.EntityTypeEnvironment { + category = entity.Entity_CATEGORY_ENVIRONMENT } - if entityV2 == nil { - if clientID, exists := result.Claims["client_id"]; exists { - if clientIDStr, ok := clientID.(string); ok && clientIDStr != "" { - entityV2 = &entity.Entity{ - EntityType: &entity.Entity_ClientId{ClientId: clientIDStr}, - Category: category, - } - } - } + resultData, err := claimsToResultData(result.Claims) + if err != nil { + return nil, types.WrapMultiStrategyError( + types.ErrorTypeMapping, + "failed to normalize resolved claims for entity chain", + err, + map[string]interface{}{"token_id": tokenID, "strategy": strategy.Name}, + ) } - if entityV2 == nil { - if subject, exists := result.Claims["subject"]; exists { - if subjectStr, ok := subject.(string); ok && subjectStr != "" { - entityV2 = &entity.Entity{ - EntityType: &entity.Entity_UserName{UserName: subjectStr}, - Category: category, - } - } - } + claimsStruct, err := structpb.NewStruct(resultData) + if err != nil { + return nil, types.WrapMultiStrategyError( + types.ErrorTypeMapping, + "failed to build structpb claims for entity chain", + err, + map[string]interface{}{"token_id": tokenID, "strategy": strategy.Name}, + ) } - // Fallback: use token ID as username if no suitable claim found - if entityV2 == nil { - ers.logger.WarnContext(ctx, "no suitable entity type found in claims, using token ID as fallback", - slog.String("token_id", tokenID), - slog.Any("available_claims", extractClaimNames(types.JWTClaims(result.Claims)))) - entityV2 = &entity.Entity{ - EntityType: &entity.Entity_UserName{UserName: tokenID}, - Category: category, - } + claimsAny, err := anypb.New(claimsStruct) + if err != nil { + return nil, types.WrapMultiStrategyError( + types.ErrorTypeMapping, + "failed to wrap claims payload for entity chain", + err, + map[string]interface{}{"token_id": tokenID, "strategy": strategy.Name}, + ) } - // Generate entity ID: strategy-tokenid-type-value - entityID := fmt.Sprintf("%s-%s-%s-%s", + entityID := fmt.Sprintf("%s-%s-claims-%s", strategy.Name, tokenID, - getEntityTypeStringV2(entityV2), - getEntityValueV2(entityV2.GetEntityType())) + preferredEntityValueFromClaims(result.Claims, tokenID)) - // Set the EphemeralId on the entity - entityV2.EphemeralId = entityID - return entityV2 + return &entity.Entity{ + EphemeralId: entityID, + EntityType: &entity.Entity_Claims{Claims: claimsAny}, + Category: category, + }, nil } func claimsToResultData(claims map[string]interface{}) (map[string]interface{}, error) { @@ -437,22 +465,22 @@ func getEntityTypeStringV2(entityV2 *entity.Entity) string { return "email" case *entity.Entity_ClientId: return "client_id" + case *entity.Entity_Claims: + return "claims" default: return "unknown" } } -func getEntityValueV2(entityType interface{}) string { - switch et := entityType.(type) { - case *entity.Entity_UserName: - return et.UserName - case *entity.Entity_EmailAddress: - return et.EmailAddress - case *entity.Entity_ClientId: - return et.ClientId - default: - return "unknown" +func preferredEntityValueFromClaims(claims map[string]interface{}, fallback string) string { + for _, key := range []string{"username", "email_address", "client_id", "subject"} { + if raw, exists := claims[key]; exists { + if value, ok := raw.(string); ok && value != "" { + return value + } + } } + return fallback } // RegisterMultiStrategyERSV2 registers the v2 multi-strategy ERS service diff --git a/service/entityresolution/multi-strategy/v2/registration_test.go b/service/entityresolution/multi-strategy/v2/registration_test.go index 4dc82b6a81..7a77944ba8 100644 --- a/service/entityresolution/multi-strategy/v2/registration_test.go +++ b/service/entityresolution/multi-strategy/v2/registration_test.go @@ -1,6 +1,7 @@ package multistrategy import ( + "errors" "testing" "connectrpc.com/connect" @@ -325,3 +326,100 @@ func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { t.Fatalf("expected entity_id alice-user-name, got %v", got) } } + +func TestCreateEntityFromResultV2ExcludesResolutionMetadataFromPolicyClaims(t *testing.T) { + ers := &ERSV2{logger: logger.CreateTestLogger()} + result := &types.EntityResult{ + Claims: map[string]interface{}{ + "username": "alice", + "department": "engineering", + }, + Metadata: map[string]interface{}{ + "strategy_name": "sql_subject", + "strategy_provider": "directory", + "provider_type": "sql", + }, + } + strategy := &types.MappingStrategy{ + Name: "sql_subject", + EntityType: types.EntityTypeSubject, + } + + resolved, err := ers.createEntityFromResultV2(t.Context(), result, strategy, "token-1") + require.NoError(t, err) + require.Equal(t, entity.Entity_CATEGORY_SUBJECT, resolved.GetCategory()) + require.NotNil(t, resolved.GetClaims()) + + var claimsStruct structpb.Struct + require.NoError(t, resolved.GetClaims().UnmarshalTo(&claimsStruct)) + policyClaims := claimsStruct.AsMap() + + require.Equal(t, "alice", policyClaims["username"]) + require.Equal(t, "engineering", policyClaims["department"]) + require.NotContains(t, policyClaims, "strategy_name") + require.NotContains(t, policyClaims, "strategy_provider") + require.NotContains(t, policyClaims, "provider_type") + require.NotContains(t, policyClaims, "metadata_strategy_name") + require.NotContains(t, policyClaims, "metadata_strategy_provider") + require.NotContains(t, policyClaims, "metadata_provider_type") +} + +func TestCreateEntityFromResultV2RejectsUnserializableClaims(t *testing.T) { + ers := &ERSV2{logger: logger.CreateTestLogger()} + result := &types.EntityResult{ + Claims: map[string]interface{}{ + "username": "alice", + "unsupported": make(chan int), + }, + } + strategy := &types.MappingStrategy{ + Name: "sql_subject", + EntityType: types.EntityTypeSubject, + } + + resolved, err := ers.createEntityFromResultV2(t.Context(), result, strategy, "token-1") + require.Error(t, err) + require.Nil(t, resolved) + require.ErrorContains(t, err, "failed to normalize resolved claims for entity chain") +} + +func TestCreateEntityForTokenChainFailsClosedOnSerializationErrorWithContinue(t *testing.T) { + ers := &ERSV2{logger: logger.CreateTestLogger()} + result := &types.EntityResult{ + Claims: map[string]interface{}{ + "username": "alice", + "unsupported": make(chan int), + }, + } + strategy := &types.MappingStrategy{ + Name: "bad_subject", + EntityType: types.EntityTypeSubject, + } + + resolved, err := ers.createEntityForTokenChain( + t.Context(), + result, + strategy, + "token-1", + types.FailureStrategyContinue, + []string{"bad_subject"}, + ) + require.Error(t, err) + require.Nil(t, resolved) + require.ErrorContains(t, err, "resolved entity serialization failed after successful strategy resolution") + require.ErrorContains(t, err, "failed to normalize resolved claims for entity chain") + + var outer *types.MultiStrategyError + require.ErrorAs(t, err, &outer) + require.Equal(t, types.ErrorTypeMapping, outer.Type) + require.Equal(t, "token-1", outer.Context["token_id"]) + require.Equal(t, "bad_subject", outer.Context["strategy"]) + require.Equal(t, types.FailureStrategyContinue, outer.Context["failure_strategy"]) + require.Equal(t, []string{"bad_subject"}, outer.Context["attempted_strategies"]) + + var inner *types.MultiStrategyError + require.ErrorAs(t, errors.Unwrap(err), &inner) + require.Equal(t, types.ErrorTypeMapping, inner.Type) + require.Equal(t, "token-1", inner.Context["token_id"]) + require.Equal(t, "bad_subject", inner.Context["strategy"]) +} diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index ded01e3475..9a22888589 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "strconv" "strings" "github.com/opentdf/platform/lib/flattening" @@ -14,7 +15,9 @@ import ( "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/protocol/go/policy/subjectmapping" otdfSDK "github.com/opentdf/platform/sdk" + ent "github.com/opentdf/platform/service/entity" ctxAuth "github.com/opentdf/platform/service/pkg/auth" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/wrapperspb" "github.com/opentdf/platform/service/internal/access/v2/obligations" @@ -30,7 +33,8 @@ var ( ErrResourceDecisionLengthMismatch = errors.New("access: resource decision length mismatch") ErrResourceDecisionIDMismatch = errors.New("access: resource decision ID mismatch") - requestAuthTokenEphemeralID = "with-request-token-auth-entity" + errResolvedTokenChainRequiresHydration = errors.New("access: resolved token chain requires ERS hydration") + requestAuthTokenEphemeralID = "with-request-token-auth-entity" ) type JustInTimePDP struct { @@ -361,30 +365,20 @@ func (p *JustInTimePDP) getMatchedSubjectMappings( return rsp.GetSubjectMappings(), nil } -// resolveEntitiesFromEntityChain roundtrips to ERS to resolve the provided entity chain -// and optionally skips environment entities (which is expected behavior in decision flow) +// resolveEntitiesFromEntityChain roundtrips caller-provided entity chains through ERS. func (p *JustInTimePDP) resolveEntitiesFromEntityChain( ctx context.Context, entityChain *entity.EntityChain, skipEnvironmentEntities bool, ) ([]*entityresolutionV2.EntityRepresentation, error) { - p.logger.DebugContext(ctx, + p.logger.DebugContext( + ctx, "resolving entities from entity chain", slog.String("entity_chain_id", entityChain.GetEphemeralId()), slog.Bool("skip_environment_entities", skipEnvironmentEntities), ) - var filteredEntities []*entity.Entity - if skipEnvironmentEntities { - for _, chained := range entityChain.GetEntities() { - if chained.GetCategory() == entity.Entity_CATEGORY_ENVIRONMENT { - continue - } - filteredEntities = append(filteredEntities, chained) - } - } else { - filteredEntities = entityChain.GetEntities() - } + filteredEntities := filterEntityChain(entityChain, skipEnvironmentEntities) if len(filteredEntities) == 0 { return nil, errors.New("no subject entities to resolve - all were environment entities and skipped") } @@ -395,7 +389,51 @@ func (p *JustInTimePDP) resolveEntitiesFromEntityChain( } entityRepresentations := ersResp.GetEntityRepresentations() if entityRepresentations == nil { - return nil, fmt.Errorf("failed to get entity representations: %w", err) + return nil, errors.New("failed to get entity representations") + } + return entityRepresentations, nil +} + +func filterEntityChain(entityChain *entity.EntityChain, skipEnvironmentEntities bool) []*entity.Entity { + if !skipEnvironmentEntities { + return entityChain.GetEntities() + } + + filteredEntities := make([]*entity.Entity, 0, len(entityChain.GetEntities())) + for _, chained := range entityChain.GetEntities() { + if chained.GetCategory() != entity.Entity_CATEGORY_ENVIRONMENT { + filteredEntities = append(filteredEntities, chained) + } + } + return filteredEntities +} + +func entityRepresentationsFromResolvedChain(entityChain *entity.EntityChain, skipEnvironmentEntities bool) ([]*entityresolutionV2.EntityRepresentation, error) { + filteredEntities := filterEntityChain(entityChain, skipEnvironmentEntities) + if len(filteredEntities) == 0 { + return nil, errors.New("no subject entities to resolve - all were environment entities and skipped") + } + + entityRepresentations := make([]*entityresolutionV2.EntityRepresentation, 0, len(filteredEntities)) + for idx, chained := range filteredEntities { + claims := chained.GetClaims() + if claims == nil { + return nil, fmt.Errorf("%w: entity %s does not contain claims", errResolvedTokenChainRequiresHydration, chained.GetEphemeralId()) + } + + var claimsStruct structpb.Struct + if err := claims.UnmarshalTo(&claimsStruct); err != nil { + return nil, fmt.Errorf("failed to unpack resolved token chain entity %s: %w", chained.GetEphemeralId(), err) + } + + originalID := chained.GetEphemeralId() + if originalID == "" { + originalID = ent.EntityIDPrefix + strconv.Itoa(idx) + } + entityRepresentations = append(entityRepresentations, &entityresolutionV2.EntityRepresentation{ + OriginalId: originalID, + AdditionalProps: []*structpb.Struct{&claimsStruct}, + }) } return entityRepresentations, nil } @@ -418,7 +456,12 @@ func (p *JustInTimePDP) resolveEntitiesFromToken( if len(entityChains) != 1 { return nil, fmt.Errorf("received %d entity chains in ERS response but expected exactly 1", len(entityChains)) } - return p.resolveEntitiesFromEntityChain(ctx, entityChains[0], skipEnvironmentEntities) + + entityRepresentations, err := entityRepresentationsFromResolvedChain(entityChains[0], skipEnvironmentEntities) + if errors.Is(err, errResolvedTokenChainRequiresHydration) { + return p.resolveEntitiesFromEntityChain(ctx, entityChains[0], skipEnvironmentEntities) + } + return entityRepresentations, err } // resolveEntitiesFromRequestToken pulls the request token off the context where it has been set upstream diff --git a/service/internal/access/v2/just_in_time_pdp_test.go b/service/internal/access/v2/just_in_time_pdp_test.go new file mode 100644 index 0000000000..b218dc5256 --- /dev/null +++ b/service/internal/access/v2/just_in_time_pdp_test.go @@ -0,0 +1,207 @@ +package access + +import ( + "context" + "errors" + "testing" + + authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + otdfSDK "github.com/opentdf/platform/sdk" + "github.com/opentdf/platform/service/logger" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" +) + +type typedChainERSClient struct { + createCalls int + resolveCalls int + createReq *entityresolutionV2.CreateEntityChainsFromTokensRequest + resolveReq *entityresolutionV2.ResolveEntitiesRequest +} + +func (c *typedChainERSClient) CreateEntityChainsFromTokens(_ context.Context, req *entityresolutionV2.CreateEntityChainsFromTokensRequest) (*entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { + c.createCalls++ + c.createReq = req + return &entityresolutionV2.CreateEntityChainsFromTokensResponse{EntityChains: []*entity.EntityChain{{ + Entities: []*entity.Entity{ + { + EphemeralId: "typed-user", + EntityType: &entity.Entity_UserName{UserName: "alice"}, + Category: entity.Entity_CATEGORY_SUBJECT, + }, + { + EphemeralId: "typed-env", + EntityType: &entity.Entity_ClientId{ClientId: "client-1"}, + Category: entity.Entity_CATEGORY_ENVIRONMENT, + }, + }, + }}}, nil +} + +func (c *typedChainERSClient) ResolveEntities(_ context.Context, req *entityresolutionV2.ResolveEntitiesRequest) (*entityresolutionV2.ResolveEntitiesResponse, error) { + c.resolveCalls++ + c.resolveReq = req + return &entityresolutionV2.ResolveEntitiesResponse{EntityRepresentations: []*entityresolutionV2.EntityRepresentation{{OriginalId: "typed-user"}}}, nil +} + +type claimsChainERSClient struct { + createCalls int + resolveCalls int + claims *anypb.Any + createReq *entityresolutionV2.CreateEntityChainsFromTokensRequest +} + +func (c *claimsChainERSClient) CreateEntityChainsFromTokens(_ context.Context, req *entityresolutionV2.CreateEntityChainsFromTokensRequest) (*entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { + c.createCalls++ + c.createReq = req + return &entityresolutionV2.CreateEntityChainsFromTokensResponse{EntityChains: []*entity.EntityChain{{ + Entities: []*entity.Entity{ + { + EphemeralId: "claims-user", + EntityType: &entity.Entity_Claims{Claims: c.claims}, + Category: entity.Entity_CATEGORY_SUBJECT, + }, + { + EphemeralId: "claims-env", + EntityType: &entity.Entity_Claims{Claims: c.claims}, + Category: entity.Entity_CATEGORY_ENVIRONMENT, + }, + }, + }}}, nil +} + +func (c *claimsChainERSClient) ResolveEntities(_ context.Context, _ *entityresolutionV2.ResolveEntitiesRequest) (*entityresolutionV2.ResolveEntitiesResponse, error) { + c.resolveCalls++ + return nil, errors.New("unexpected ResolveEntities call") +} + +func TestResolveEntitiesFromTokenUsesResolvedClaimsWithoutHydration(t *testing.T) { + claimsStruct, err := structpb.NewStruct(map[string]interface{}{ + "username": "alice", + "department": "engineering", + }) + require.NoError(t, err) + claimsAny, err := anypb.New(claimsStruct) + require.NoError(t, err) + + client := &claimsChainERSClient{claims: claimsAny} + pdp := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{EntityResolutionV2: client}, + } + resources := []*authzV2.Resource{{EphemeralId: "resource-1"}} + token := &entity.Token{EphemeralId: "token", Jwt: "token"} + + reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, true, resources) + require.NoError(t, err) + require.Len(t, reps, 1) + require.Equal(t, 1, client.createCalls) + require.Zero(t, client.resolveCalls) + require.NotNil(t, client.createReq) + require.Len(t, client.createReq.GetTokens(), 1) + require.Equal(t, token.GetEphemeralId(), client.createReq.GetTokens()[0].GetEphemeralId()) + require.Equal(t, token.GetJwt(), client.createReq.GetTokens()[0].GetJwt()) + require.Len(t, client.createReq.GetResources(), 1) + require.Equal(t, "resource-1", client.createReq.GetResources()[0].GetEphemeralId()) + require.Len(t, reps[0].GetAdditionalProps(), 1) + require.Equal(t, "alice", reps[0].GetAdditionalProps()[0].AsMap()["username"]) + require.Equal(t, "engineering", reps[0].GetAdditionalProps()[0].AsMap()["department"]) +} + +func TestResolveEntitiesFromTokenFallsBackToHydrationForTypedChain(t *testing.T) { + client := &typedChainERSClient{} + pdp := &JustInTimePDP{ + logger: logger.CreateTestLogger(), + sdk: &otdfSDK.SDK{EntityResolutionV2: client}, + } + resources := []*authzV2.Resource{{EphemeralId: "resource-1"}} + token := &entity.Token{EphemeralId: "token", Jwt: "token"} + + reps, err := pdp.resolveEntitiesFromToken(t.Context(), token, true, resources) + require.NoError(t, err) + require.Len(t, reps, 1) + require.Equal(t, 1, client.createCalls) + require.Equal(t, 1, client.resolveCalls) + require.NotNil(t, client.createReq) + require.Len(t, client.createReq.GetTokens(), 1) + require.Equal(t, token.GetEphemeralId(), client.createReq.GetTokens()[0].GetEphemeralId()) + require.Equal(t, token.GetJwt(), client.createReq.GetTokens()[0].GetJwt()) + require.Len(t, client.createReq.GetResources(), 1) + require.Equal(t, "resource-1", client.createReq.GetResources()[0].GetEphemeralId()) + require.NotNil(t, client.resolveReq) + require.Len(t, client.resolveReq.GetEntities(), 1) + require.Equal(t, "typed-user", client.resolveReq.GetEntities()[0].GetEphemeralId()) + require.Equal(t, entity.Entity_CATEGORY_SUBJECT, client.resolveReq.GetEntities()[0].GetCategory()) + require.IsType(t, &entity.Entity_UserName{}, client.resolveReq.GetEntities()[0].GetEntityType()) +} + +func TestEntityRepresentationsFromResolvedChain(t *testing.T) { + claimsStruct, err := structpb.NewStruct(map[string]interface{}{ + "username": "alice", + "department": "engineering", + }) + if err != nil { + t.Fatalf("structpb.NewStruct() error = %v", err) + } + + claimsAny, err := anypb.New(claimsStruct) + if err != nil { + t.Fatalf("anypb.New() error = %v", err) + } + + chain := &entity.EntityChain{ + EphemeralId: "token-alice", + Entities: []*entity.Entity{ + { + EphemeralId: "user-strategy-token-alice", + EntityType: &entity.Entity_Claims{Claims: claimsAny}, + Category: entity.Entity_CATEGORY_SUBJECT, + }, + { + EphemeralId: "client-strategy-token-alice", + EntityType: &entity.Entity_Claims{Claims: claimsAny}, + Category: entity.Entity_CATEGORY_ENVIRONMENT, + }, + }, + } + + reps, err := entityRepresentationsFromResolvedChain(chain, true) + if err != nil { + t.Fatalf("entityRepresentationsFromResolvedChain() error = %v", err) + } + + if got := len(reps); got != 1 { + t.Fatalf("expected 1 subject representation after skipping environment entities, got %d", got) + } + + props := reps[0].GetAdditionalProps() + if len(props) != 1 { + t.Fatalf("expected 1 additional props entry, got %d", len(props)) + } + + asMap := props[0].AsMap() + if got := asMap["username"]; got != "alice" { + t.Fatalf("expected username alice, got %v", got) + } + if got := asMap["department"]; got != "engineering" { + t.Fatalf("expected department engineering, got %v", got) + } +} + +func TestEntityRepresentationsFromResolvedChainRejectsTypedEntity(t *testing.T) { + chain := &entity.EntityChain{ + Entities: []*entity.Entity{{ + EphemeralId: "typed-user", + EntityType: &entity.Entity_UserName{UserName: "alice"}, + Category: entity.Entity_CATEGORY_SUBJECT, + }}, + } + + _, err := entityRepresentationsFromResolvedChain(chain, false) + if err == nil { + t.Fatal("expected typed token-chain entity to be rejected") + } +} diff --git a/tests-bdd/cukes/steps_authorization.go b/tests-bdd/cukes/steps_authorization.go index d1f562f7a5..ed8541d443 100644 --- a/tests-bdd/cukes/steps_authorization.go +++ b/tests-bdd/cukes/steps_authorization.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "net" + "strconv" "strings" "github.com/cucumber/godog" @@ -118,6 +120,32 @@ func (s *AuthorizationServiceStepDefinitions) thereIsAClaimsSubjectEntityReferen return ctx, nil } +func (s *AuthorizationServiceStepDefinitions) aUserAccessTokenForStoredAs(ctx context.Context, username, ref string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + localPlatformGlue, ok := (*scenarioContext.TestSuiteContext.PlatformGlue).(*LocalDevPlatformGlue) + if !ok { + return ctx, errors.New("failed to load local platform glue") + } + + kcHostPort := net.JoinHostPort(localPlatformGlue.Options.Hostname, strconv.Itoa(localPlatformGlue.Options.keycloakPort)) + tokenURL := fmt.Sprintf( + "http://%s/auth/realms/%s/protocol/openid-connect/token", + kcHostPort, + scenarioContext.ScenarioOptions.KeycloakRealm, + ) + + token, err := fetchUserAccessToken(ctx, tokenURL, username) + if err != nil { + return ctx, fmt.Errorf("fetch access token for user %q: %w", username, err) + } + if token.AccessToken == "" { + return ctx, fmt.Errorf("user token for %q missing access token", username) + } + + scenarioContext.RecordObject(ref, token.AccessToken) + return ctx, nil +} + func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResource(ctx context.Context, entityChainID, action, resource string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -301,6 +329,43 @@ func getAllObligationsFromScenario(scenarioContext *PlatformScenarioContext) []s return obligationFQNs } +func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForTokenForActionOnResource(ctx context.Context, tokenRef, action, resource string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + rawToken, ok := scenarioContext.GetObject(tokenRef).(string) + if !ok || rawToken == "" { + return ctx, fmt.Errorf("no raw token stored under %q", tokenRef) + } + + var resourceFQNs []string + for r := range strings.SplitSeq(resource, ",") { + resourceFQNs = append(resourceFQNs, strings.TrimSpace(r)) + } + + req := &authzV2.GetDecisionRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_Token{ + Token: &entity.Token{EphemeralId: tokenRef, Jwt: rawToken}, + }, + }, + Action: &policy.Action{Name: strings.ToLower(action)}, + Resource: &authzV2.Resource{ + EphemeralId: "resource1", + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{Fqns: resourceFQNs}, + }, + }, + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) + if err != nil { + return ctx, err + } + scenarioContext.RecordObject(decisionResponse, resp) + return ctx, nil +} + func buildEntityChainFromIDs(scenarioContext *PlatformScenarioContext, entityChainID string) (*entity.EntityChain, error) { var entities []*entity.Entity for _, entityID := range strings.Split(entityChainID, ",") { @@ -484,8 +549,10 @@ func RegisterAuthorizationStepDefinitions(ctx *godog.ScenarioContext) { stepDefinitions := AuthorizationServiceStepDefinitions{} ctx.Step(`^there is a "([^"]*)" subject entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsASubjectEntityWithValueAndReferencedAs) ctx.Step(`^there is a claims subject entity referenced as "([^"]*)" with claims:$`, stepDefinitions.thereIsAClaimsSubjectEntityReferencedAsWithClaims) + ctx.Step(`^a user access token for "([^"]*)" stored as "([^"]*)"$`, stepDefinitions.aUserAccessTokenForStoredAs) ctx.Step(`^there is a "([^"]*)" environment entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsAEnvEntityWithValueAndReferencedAs) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResource) + ctx.Step(`^I send a decision request for token "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForTokenForActionOnResource) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with fulfillable obligations "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with no fulfillable obligations$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithNoFulfillableObligations) ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources) diff --git a/tests-bdd/features/multi-strategy-ers-token.feature b/tests-bdd/features/multi-strategy-ers-token.feature new file mode 100644 index 0000000000..c48a076cf3 --- /dev/null +++ b/tests-bdd/features/multi-strategy-ers-token.feature @@ -0,0 +1,79 @@ +@multi-strategy-ers-token @stateless +Feature: Multi-strategy ERS token decision flow + Validate that a token-based decision request uses the multi-strategy ERS chain as + the resolved authorization context, without requiring a second ERS rehydration pass. + + Background: + Given a user exists with username "alice" and email "alice@opentdf.test" and the following attributes: + | name | value | + | department | ["engineering"] | + And a user exists with username "bob" and email "bob@opentdf.test" and the following attributes: + | name | value | + | department | ["marketing"] | + And an LDAP directory with test users + And an ERS configuration with mode "multi-strategy" and failure strategy "continue" + And an ERS provider "ldap_directory" of type "ldap" connected to the LDAP directory + And an ERS mapping strategy "ldap_by_username" using provider "ldap_directory" + """ + entity_type: subject + conditions: + jwt_claims: + - claim: preferred_username + operator: exists + ldap_search: + base_dn: "ou=users,dc=opentdf,dc=test" + filter: "(&(objectClass=inetOrgPerson)(uid={username}))" + scope: subtree + attributes: ["uid", "mail", "departmentNumber"] + input_mapping: + - jwt_claim: preferred_username + parameter: username + output_mapping: + - source_attribute: departmentNumber + claim_name: department + - source_attribute: mail + claim_name: email + - source_attribute: uid + claim_name: username + """ + And a local platform with inline ERS configuration + + Scenario: Token for engineering user gets PERMIT for engineering resource + Given I submit a request to create a namespace with name "token-eng-permit.test" and reference id "ns_token_eng_permit" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns_token_eng_permit | department | anyOf | engineering,marketing,security | + Then the response should be successful + Given a condition group referenced as "cg_token_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .department | in | engineering | + And a subject set referenced as "ss_token_eng" containing the condition groups "cg_token_eng" + And I send a request to create a subject condition set referenced as "scs_token_eng" containing subject sets "ss_token_eng" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_token_eng | https://token-eng-permit.test/attr/department/value/engineering | scs_token_eng | read | | + Then the response should be successful + Given a user access token for "alice" stored as "alice_access_token" + When I send a decision request for token "alice_access_token" for "read" action on resource "https://token-eng-permit.test/attr/department/value/engineering" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Token for marketing user gets DENY for engineering resource + Given I submit a request to create a namespace with name "token-eng-deny.test" and reference id "ns_token_eng_deny" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns_token_eng_deny | department | anyOf | engineering,marketing,security | + Then the response should be successful + Given a condition group referenced as "cg_token_eng2" with an "or" operator with conditions: + | selector_value | operator | values | + | .department | in | engineering | + And a subject set referenced as "ss_token_eng2" containing the condition groups "cg_token_eng2" + And I send a request to create a subject condition set referenced as "scs_token_eng2" containing subject sets "ss_token_eng2" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_token_eng2 | https://token-eng-deny.test/attr/department/value/engineering | scs_token_eng2 | read | | + Then the response should be successful + Given a user access token for "bob" stored as "bob_access_token" + When I send a decision request for token "bob_access_token" for "read" action on resource "https://token-eng-deny.test/attr/department/value/engineering" + Then the response should be successful + And I should get a "DENY" decision response