From 75178102c5250869934c553d8685f9ea6de24199 Mon Sep 17 00:00:00 2001 From: Ryan Schumacher Date: Tue, 28 Jul 2026 15:37:33 -0400 Subject: [PATCH 1/5] fix(entityresolution): pass inline claims through multi-strategy context Signed-off-by: Ryan Schumacher --- .../multi-strategy/registration.go | 32 +++++- .../multi-strategy/registration_test.go | 102 ++++++++++++++++++ .../multi-strategy/v2/registration.go | 30 +++++- .../multi-strategy/v2/registration_test.go | 102 ++++++++++++++++-- 4 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 service/entityresolution/multi-strategy/registration_test.go diff --git a/service/entityresolution/multi-strategy/registration.go b/service/entityresolution/multi-strategy/registration.go index 6feed3e6ac..c801c15faf 100644 --- a/service/entityresolution/multi-strategy/registration.go +++ b/service/entityresolution/multi-strategy/registration.go @@ -80,7 +80,8 @@ func (ers *ERS) ResolveEntities( } // Resolve entity using multi-strategy service - result, err := ers.service.ResolveEntity(ctx, entityID, claimsMap) + ctxWithClaims := context.WithValue(ctx, types.JWTClaimsContextKey, claimsMap) + result, err := ers.service.ResolveEntity(ctxWithClaims, entityID, claimsMap) if err != nil { ers.logger.Error("failed to resolve entity", slog.String("entity_id", entityID), @@ -109,12 +110,12 @@ func (ers *ERS) ResolveEntities( // Add resolved claims for claimName, claimValue := range result.Claims { - resultData[claimName] = claimValue + resultData[claimName] = structPBCompatibleValue(claimValue) } // Add metadata with "metadata_" prefix for metaKey, metaValue := range result.Metadata { - resultData[("metadata_" + metaKey)] = metaValue + resultData[("metadata_" + metaKey)] = structPBCompatibleValue(metaValue) } // Convert to protobuf struct @@ -445,3 +446,28 @@ func RegisterERS(config map[string]interface{}, logger *logger.Logger) (*ERS, se return ers, nil } + +func structPBCompatibleValue(value interface{}) interface{} { + switch v := value.(type) { + case []string: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = item + } + return result + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = structPBCompatibleValue(item) + } + return result + case map[string]interface{}: + result := make(map[string]interface{}, len(v)) + for key, item := range v { + result[key] = structPBCompatibleValue(item) + } + return result + default: + return value + } +} diff --git a/service/entityresolution/multi-strategy/registration_test.go b/service/entityresolution/multi-strategy/registration_test.go new file mode 100644 index 0000000000..f1f9f8820c --- /dev/null +++ b/service/entityresolution/multi-strategy/registration_test.go @@ -0,0 +1,102 @@ +package multistrategy + +import ( + "testing" + + "connectrpc.com/connect" + "github.com/opentdf/platform/protocol/go/authorization" + "github.com/opentdf/platform/protocol/go/entityresolution" + "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" + "github.com/opentdf/platform/service/logger" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { + t.Helper() + + erService, err := NewERS(t.Context(), types.MultiStrategyConfig{ + Providers: map[string]types.ProviderConfig{ + "jwt": { + Type: "claims", + Connection: map[string]interface{}{}, + }, + }, + MappingStrategies: []types.MappingStrategy{ + { + Name: "claims_passthrough", + Provider: "jwt", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{ + JWTClaims: []types.JWTClaimCondition{ + { + Claim: "sub", + Operator: "exists", + }, + }, + }, + OutputMapping: []types.OutputMapping{ + { + SourceClaim: "sub", + ClaimName: "subject", + }, + { + SourceClaim: "email", + ClaimName: "email_address", + }, + }, + }, + }, + }, logger.CreateTestLogger()) + if err != nil { + t.Fatalf("NewERS() error = %v", err) + } + + claimsStruct, err := structpb.NewStruct(map[string]interface{}{ + "sub": "diana", + "email": "diana@example.com", + }) + 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) + } + + resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&entityresolution.ResolveEntitiesRequest{ + Entities: []*authorization.Entity{ + { + Id: "diana-claims", + EntityType: &authorization.Entity_Claims{Claims: claimsAny}, + }, + }, + })) + if err != nil { + t.Fatalf("ResolveEntities() error = %v", err) + } + + if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { + t.Fatalf("expected 1 entity representation, got %d", got) + } + + props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() + if len(props) != 1 { + t.Fatalf("expected 1 additional props entry, got %d", len(props)) + } + + result := props[0].AsMap() + if got := result["subject"]; got != "diana" { + t.Fatalf("expected subject diana, got %v", got) + } + if got := result["email_address"]; got != "diana@example.com" { + t.Fatalf("expected email_address diana@example.com, got %v", got) + } + if got := result["metadata_source"]; got != "jwt_claims" { + t.Fatalf("expected metadata_source jwt_claims, got %v", got) + } + if _, hasError := result["error"]; hasError { + t.Fatalf("expected successful resolution, got error payload: %v", result["error"]) + } +} diff --git a/service/entityresolution/multi-strategy/v2/registration.go b/service/entityresolution/multi-strategy/v2/registration.go index 1599906f0f..f0f54bc51a 100644 --- a/service/entityresolution/multi-strategy/v2/registration.go +++ b/service/entityresolution/multi-strategy/v2/registration.go @@ -89,7 +89,8 @@ func (ers *ERSV2) ResolveEntities( } // Resolve entity using multi-strategy service - result, err := ers.service.ResolveEntity(ctx, entityID, claimsMap) + ctxWithClaims := context.WithValue(ctx, types.JWTClaimsContextKey, claimsMap) + result, err := ers.service.ResolveEntity(ctxWithClaims, entityID, claimsMap) if err != nil { ers.logger.Error("failed to resolve entity", slog.String("entity_id", entityID), @@ -123,7 +124,7 @@ func (ers *ERSV2) ResolveEntities( // Add metadata with "metadata_" prefix for metaKey, metaValue := range result.Metadata { - resultData[("metadata_" + metaKey)] = metaValue + resultData[("metadata_" + metaKey)] = structPBCompatibleValue(metaValue) } // Convert to protobuf struct @@ -478,3 +479,28 @@ func extractClaimNames(claims types.JWTClaims) []string { } return names } + +func structPBCompatibleValue(value interface{}) interface{} { + switch v := value.(type) { + case []string: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = item + } + return result + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = structPBCompatibleValue(item) + } + return result + case map[string]interface{}: + result := make(map[string]interface{}, len(v)) + for key, item := range v { + result[key] = structPBCompatibleValue(item) + } + return result + default: + return value + } +} diff --git a/service/entityresolution/multi-strategy/v2/registration_test.go b/service/entityresolution/multi-strategy/v2/registration_test.go index 448c4aacf7..83389c38be 100644 --- a/service/entityresolution/multi-strategy/v2/registration_test.go +++ b/service/entityresolution/multi-strategy/v2/registration_test.go @@ -1,7 +1,6 @@ package multistrategy import ( - "context" "testing" "connectrpc.com/connect" @@ -137,16 +136,7 @@ func TestERSV2_ResolveEntities_PopulatesRepresentations(t *testing.T) { }, }) - // The claims provider reads JWT claims from ctx (see - // providers/claims/claims_provider.go). In production, upstream callers - // (admin API middleware, JustInTimePDP) populate this before invoking - // the handler; we mirror that here so the strategy actually succeeds - // and we exercise the serialization path where the bug lives. - ctx := context.WithValue(t.Context(), types.JWTClaimsContextKey, types.JWTClaims{ - "sub": "alice", - "email": "alice@example.com", - }) - resp, err := ers.ResolveEntities(ctx, req) + resp, err := ers.ResolveEntities(t.Context(), req) if err != nil { t.Fatalf("ResolveEntities returned error: %v", err) } @@ -189,3 +179,93 @@ func TestERSV2_ResolveEntities_PopulatesRepresentations(t *testing.T) { t.Errorf("metadata_attempted_strategies[0] = %q, want %q", got, "jwt_strategy") } } + +func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { + t.Helper() + + erService, err := NewERSV2(t.Context(), types.MultiStrategyConfig{ + Providers: map[string]types.ProviderConfig{ + "jwt": { + Type: "claims", + Connection: map[string]interface{}{}, + }, + }, + MappingStrategies: []types.MappingStrategy{ + { + Name: "claims_passthrough", + Provider: "jwt", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{ + JWTClaims: []types.JWTClaimCondition{ + { + Claim: "sub", + Operator: "exists", + }, + }, + }, + OutputMapping: []types.OutputMapping{ + { + SourceClaim: "sub", + ClaimName: "subject", + }, + { + SourceClaim: "email", + ClaimName: "email_address", + }, + }, + }, + }, + }, logger.CreateTestLogger()) + if err != nil { + t.Fatalf("NewERSV2() error = %v", err) + } + + claimsStruct, err := structpb.NewStruct(map[string]interface{}{ + "sub": "diana", + "email": "diana@example.com", + }) + 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) + } + + resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&ersV2.ResolveEntitiesRequest{ + Entities: []*entity.Entity{ + { + EphemeralId: "diana-claims", + EntityType: &entity.Entity_Claims{Claims: claimsAny}, + Category: entity.Entity_CATEGORY_SUBJECT, + }, + }, + })) + if err != nil { + t.Fatalf("ResolveEntities() error = %v", err) + } + + if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { + t.Fatalf("expected 1 entity representation, got %d", got) + } + + props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() + if len(props) != 1 { + t.Fatalf("expected 1 additional props entry, got %d", len(props)) + } + + result := props[0].AsMap() + if got := result["subject"]; got != "diana" { + t.Fatalf("expected subject diana, got %v", got) + } + if got := result["email_address"]; got != "diana@example.com" { + t.Fatalf("expected email_address diana@example.com, got %v", got) + } + if got := result["metadata_source"]; got != "jwt_claims" { + t.Fatalf("expected metadata_source jwt_claims, got %v", got) + } + if _, hasError := result["error"]; hasError { + t.Fatalf("expected successful resolution, got error payload: %v", result["error"]) + } +} From 9c9b8d8b0446d540f26344ff7e8c8422b5d0dc5c Mon Sep 17 00:00:00 2001 From: Ryan Schumacher Date: Tue, 28 Jul 2026 17:15:53 -0400 Subject: [PATCH 2/5] refactor(entityresolution): reuse shared structpb conversion helper Signed-off-by: Ryan Schumacher --- .../multi-strategy/registration.go | 10 +++---- .../multi-strategy/v2/registration.go | 27 +------------------ 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/service/entityresolution/multi-strategy/registration.go b/service/entityresolution/multi-strategy/registration.go index c801c15faf..4e0b8fc196 100644 --- a/service/entityresolution/multi-strategy/registration.go +++ b/service/entityresolution/multi-strategy/registration.go @@ -110,12 +110,12 @@ func (ers *ERS) ResolveEntities( // Add resolved claims for claimName, claimValue := range result.Claims { - resultData[claimName] = structPBCompatibleValue(claimValue) + resultData[claimName] = StructPBCompatibleValue(claimValue) } // Add metadata with "metadata_" prefix for metaKey, metaValue := range result.Metadata { - resultData[("metadata_" + metaKey)] = structPBCompatibleValue(metaValue) + resultData[("metadata_" + metaKey)] = StructPBCompatibleValue(metaValue) } // Convert to protobuf struct @@ -447,7 +447,7 @@ func RegisterERS(config map[string]interface{}, logger *logger.Logger) (*ERS, se return ers, nil } -func structPBCompatibleValue(value interface{}) interface{} { +func StructPBCompatibleValue(value interface{}) interface{} { switch v := value.(type) { case []string: result := make([]interface{}, len(v)) @@ -458,13 +458,13 @@ func structPBCompatibleValue(value interface{}) interface{} { case []interface{}: result := make([]interface{}, len(v)) for i, item := range v { - result[i] = structPBCompatibleValue(item) + result[i] = StructPBCompatibleValue(item) } return result case map[string]interface{}: result := make(map[string]interface{}, len(v)) for key, item := range v { - result[key] = structPBCompatibleValue(item) + result[key] = StructPBCompatibleValue(item) } return result default: diff --git a/service/entityresolution/multi-strategy/v2/registration.go b/service/entityresolution/multi-strategy/v2/registration.go index f0f54bc51a..ed6593d12e 100644 --- a/service/entityresolution/multi-strategy/v2/registration.go +++ b/service/entityresolution/multi-strategy/v2/registration.go @@ -124,7 +124,7 @@ func (ers *ERSV2) ResolveEntities( // Add metadata with "metadata_" prefix for metaKey, metaValue := range result.Metadata { - resultData[("metadata_" + metaKey)] = structPBCompatibleValue(metaValue) + resultData[("metadata_" + metaKey)] = multistrategy.StructPBCompatibleValue(metaValue) } // Convert to protobuf struct @@ -479,28 +479,3 @@ func extractClaimNames(claims types.JWTClaims) []string { } return names } - -func structPBCompatibleValue(value interface{}) interface{} { - switch v := value.(type) { - case []string: - result := make([]interface{}, len(v)) - for i, item := range v { - result[i] = item - } - return result - case []interface{}: - result := make([]interface{}, len(v)) - for i, item := range v { - result[i] = structPBCompatibleValue(item) - } - return result - case map[string]interface{}: - result := make(map[string]interface{}, len(v)) - for key, item := range v { - result[key] = structPBCompatibleValue(item) - } - return result - default: - return value - } -} From 88c77a4113468e021f3969770f771b4d2696c301 Mon Sep 17 00:00:00 2001 From: Ryan Schumacher Date: Tue, 28 Jul 2026 17:20:05 -0400 Subject: [PATCH 3/5] refactor(service): extract structpb normalization helper Signed-off-by: Ryan Schumacher --- .../multi-strategy/registration.go | 30 ++------------- .../multi-strategy/v2/registration.go | 3 +- service/pkg/protohelper/structpb.go | 29 +++++++++++++++ service/pkg/protohelper/structpb_test.go | 37 +++++++++++++++++++ 4 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 service/pkg/protohelper/structpb.go create mode 100644 service/pkg/protohelper/structpb_test.go diff --git a/service/entityresolution/multi-strategy/registration.go b/service/entityresolution/multi-strategy/registration.go index 4e0b8fc196..8d3ff86c5f 100644 --- a/service/entityresolution/multi-strategy/registration.go +++ b/service/entityresolution/multi-strategy/registration.go @@ -13,6 +13,7 @@ import ( "github.com/opentdf/platform/protocol/go/entityresolution" "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/pkg/protohelper" "github.com/opentdf/platform/service/pkg/serviceregistry" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/encoding/protojson" @@ -110,12 +111,12 @@ func (ers *ERS) ResolveEntities( // Add resolved claims for claimName, claimValue := range result.Claims { - resultData[claimName] = StructPBCompatibleValue(claimValue) + resultData[claimName] = protohelper.StructPBCompatibleValue(claimValue) } // Add metadata with "metadata_" prefix for metaKey, metaValue := range result.Metadata { - resultData[("metadata_" + metaKey)] = StructPBCompatibleValue(metaValue) + resultData[("metadata_" + metaKey)] = protohelper.StructPBCompatibleValue(metaValue) } // Convert to protobuf struct @@ -446,28 +447,3 @@ func RegisterERS(config map[string]interface{}, logger *logger.Logger) (*ERS, se return ers, nil } - -func StructPBCompatibleValue(value interface{}) interface{} { - switch v := value.(type) { - case []string: - result := make([]interface{}, len(v)) - for i, item := range v { - result[i] = item - } - return result - case []interface{}: - result := make([]interface{}, len(v)) - for i, item := range v { - result[i] = StructPBCompatibleValue(item) - } - return result - case map[string]interface{}: - result := make(map[string]interface{}, len(v)) - for key, item := range v { - result[key] = StructPBCompatibleValue(item) - } - return result - default: - return value - } -} diff --git a/service/entityresolution/multi-strategy/v2/registration.go b/service/entityresolution/multi-strategy/v2/registration.go index ed6593d12e..d818e11e01 100644 --- a/service/entityresolution/multi-strategy/v2/registration.go +++ b/service/entityresolution/multi-strategy/v2/registration.go @@ -16,6 +16,7 @@ import ( multistrategy "github.com/opentdf/platform/service/entityresolution/multi-strategy" "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/pkg/protohelper" "github.com/opentdf/platform/service/pkg/serviceregistry" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/encoding/protojson" @@ -124,7 +125,7 @@ func (ers *ERSV2) ResolveEntities( // Add metadata with "metadata_" prefix for metaKey, metaValue := range result.Metadata { - resultData[("metadata_" + metaKey)] = multistrategy.StructPBCompatibleValue(metaValue) + resultData[("metadata_" + metaKey)] = protohelper.StructPBCompatibleValue(metaValue) } // Convert to protobuf struct diff --git a/service/pkg/protohelper/structpb.go b/service/pkg/protohelper/structpb.go new file mode 100644 index 0000000000..e49cfc9561 --- /dev/null +++ b/service/pkg/protohelper/structpb.go @@ -0,0 +1,29 @@ +package protohelper + +// StructPBCompatibleValue normalizes Go values into shapes accepted by structpb.NewStruct. +// In particular, it recursively converts []string into []interface{} while preserving +// nested []interface{} and map[string]interface{} values. +func StructPBCompatibleValue(value interface{}) interface{} { + switch v := value.(type) { + case []string: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = item + } + return result + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = StructPBCompatibleValue(item) + } + return result + case map[string]interface{}: + result := make(map[string]interface{}, len(v)) + for key, item := range v { + result[key] = StructPBCompatibleValue(item) + } + return result + default: + return value + } +} diff --git a/service/pkg/protohelper/structpb_test.go b/service/pkg/protohelper/structpb_test.go new file mode 100644 index 0000000000..ed7244ed35 --- /dev/null +++ b/service/pkg/protohelper/structpb_test.go @@ -0,0 +1,37 @@ +package protohelper + +import ( + "reflect" + "testing" + + "google.golang.org/protobuf/types/known/structpb" +) + +func TestStructPBCompatibleValue(t *testing.T) { + input := map[string]interface{}{ + "attempted_strategies": []string{"claims", "ldap"}, + "nested": map[string]interface{}{ + "values": []interface{}{ + "ok", + []string{"a", "b"}, + map[string]interface{}{"inner": []string{"x", "y"}}, + }, + }, + } + + normalized := StructPBCompatibleValue(input) + + normalizedMap, ok := normalized.(map[string]interface{}) + if !ok { + t.Fatalf("expected normalized result to be map[string]interface{}, got %T", normalized) + } + + expectedStrategies := []interface{}{"claims", "ldap"} + if !reflect.DeepEqual(normalizedMap["attempted_strategies"], expectedStrategies) { + t.Fatalf("expected attempted_strategies %v, got %v", expectedStrategies, normalizedMap["attempted_strategies"]) + } + + if _, err := structpb.NewStruct(normalizedMap); err != nil { + t.Fatalf("expected normalized map to be structpb-compatible, got error: %v", err) + } +} From 58f51ab7267e3653ee1dd8f70146fc51a23fdb74 Mon Sep 17 00:00:00 2001 From: Ryan Schumacher Date: Wed, 29 Jul 2026 11:50:10 -0400 Subject: [PATCH 4/5] test(tests-bdd): cover direct claims multi-strategy ers path Signed-off-by: Ryan Schumacher --- tests-bdd/cukes/steps_authorization.go | 24 +++++-- tests-bdd/cukes/steps_authorization_test.go | 27 +++++++ .../multi-strategy-ers-claims.feature | 70 +++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests-bdd/cukes/steps_authorization_test.go create mode 100644 tests-bdd/features/multi-strategy-ers-claims.feature diff --git a/tests-bdd/cukes/steps_authorization.go b/tests-bdd/cukes/steps_authorization.go index 4ced33b43f..d1f562f7a5 100644 --- a/tests-bdd/cukes/steps_authorization.go +++ b/tests-bdd/cukes/steps_authorization.go @@ -12,6 +12,7 @@ import ( "github.com/opentdf/platform/protocol/go/policy" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" ) type AuthorizationServiceStepDefinitions struct{} @@ -21,14 +22,18 @@ const ( ) func ConvertInterfaceToAny(jsonData []byte) (*anypb.Any, error) { - // Create an empty Any + // First allow callers to provide a native Any JSON payload. anyMsg := &anypb.Any{} + if err := protojson.Unmarshal(jsonData, anyMsg); err == nil { + return anyMsg, nil + } - // Use protojson's Unmarshal which handles @type automatically - if err := protojson.Unmarshal(jsonData, anyMsg); err != nil { + // For claims entities in BDD, plain JSON objects are the ergonomic input. + claimsStruct := &structpb.Struct{} + if err := protojson.Unmarshal(jsonData, claimsStruct); err != nil { return nil, err } - return anyMsg, nil + return anypb.New(claimsStruct) } func GetActionsFromValues(standardActions *string, customActions *string) []*policy.Action { @@ -103,6 +108,16 @@ func (s *AuthorizationServiceStepDefinitions) thereIsASubjectEntityWithValueAndR return ctx, nil } +func (s *AuthorizationServiceStepDefinitions) thereIsAClaimsSubjectEntityReferencedAsWithClaims(ctx context.Context, referenceID string, doc *godog.DocString) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + entity, err := s.createEntity(referenceID, "SUBJECT", "claims", doc.Content) + if err != nil { + return ctx, err + } + scenarioContext.RecordObject(referenceID, entity) + return ctx, nil +} + func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResource(ctx context.Context, entityChainID, action, resource string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -468,6 +483,7 @@ func (s *AuthorizationServiceStepDefinitions) theDecisionResponseForResourceShou 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(`^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 entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with fulfillable obligations "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations) diff --git a/tests-bdd/cukes/steps_authorization_test.go b/tests-bdd/cukes/steps_authorization_test.go new file mode 100644 index 0000000000..1aae935565 --- /dev/null +++ b/tests-bdd/cukes/steps_authorization_test.go @@ -0,0 +1,27 @@ +package cukes + +import ( + "testing" + + "google.golang.org/protobuf/types/known/structpb" +) + +func TestConvertInterfaceToAny_PlainClaimsJSON(t *testing.T) { + anyMsg, err := ConvertInterfaceToAny([]byte(`{"userName":"diana","department":"engineering"}`)) + if err != nil { + t.Fatalf("ConvertInterfaceToAny() error = %v", err) + } + + var claimsStruct structpb.Struct + if err := anyMsg.UnmarshalTo(&claimsStruct); err != nil { + t.Fatalf("UnmarshalTo(structpb.Struct) error = %v", err) + } + + claims := claimsStruct.AsMap() + if got := claims["userName"]; got != "diana" { + t.Fatalf("expected userName diana, got %v", got) + } + if got := claims["department"]; got != "engineering" { + t.Fatalf("expected department engineering, got %v", got) + } +} diff --git a/tests-bdd/features/multi-strategy-ers-claims.feature b/tests-bdd/features/multi-strategy-ers-claims.feature new file mode 100644 index 0000000000..9da905abc2 --- /dev/null +++ b/tests-bdd/features/multi-strategy-ers-claims.feature @@ -0,0 +1,70 @@ +@claims-only-ers @stateless +Feature: Multi-strategy ERS direct claims resolution + Validate that multi-strategy ERS resolves inline claims entities through the full + SDK -> Connect RPC -> platform -> ERS stack. This specifically covers the + ResolveEntities path used by authorization decisions when subject entities carry + claims directly instead of relying on LDAP lookup. + + Background: + Given an ERS configuration with mode "multi-strategy" and failure strategy "fail-fast" + And an ERS provider "jwt_claims" of type "claims" + And an ERS mapping strategy "claims_passthrough" using provider "jwt_claims" + """ + entity_type: subject + conditions: + jwt_claims: + - claim: userName + operator: exists + output_mapping: + - source_claim: userName + claim_name: username + - source_claim: department + claim_name: department + """ + And a local platform with inline ERS configuration + + Scenario: Inline claims engineering user gets PERMIT + Given I submit a request to create a namespace with name "claims-permit.test" and reference id "ns_claims_permit" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns_claims_permit | department | anyOf | engineering,marketing,security | + Then the response should be successful + Given a condition group referenced as "cg_claims_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .department | in | engineering | + And a subject set referenced as "ss_claims_eng" containing the condition groups "cg_claims_eng" + And I send a request to create a subject condition set referenced as "scs_claims_eng" containing subject sets "ss_claims_eng" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_claims_eng | https://claims-permit.test/attr/department/value/engineering | scs_claims_eng | read | | + Then the response should be successful + Given there is a claims subject entity referenced as "diana_claims" with claims: + """ + {"userName":"diana","department":"engineering"} + """ + When I send a decision request for entity chain "diana_claims" for "read" action on resource "https://claims-permit.test/attr/department/value/engineering" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Inline claims marketing user gets DENY for engineering resource + Given I submit a request to create a namespace with name "claims-deny.test" and reference id "ns_claims_deny" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns_claims_deny | department | anyOf | engineering,marketing,security | + Then the response should be successful + Given a condition group referenced as "cg_claims_eng2" with an "or" operator with conditions: + | selector_value | operator | values | + | .department | in | engineering | + And a subject set referenced as "ss_claims_eng2" containing the condition groups "cg_claims_eng2" + And I send a request to create a subject condition set referenced as "scs_claims_eng2" containing subject sets "ss_claims_eng2" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_claims_eng2 | https://claims-deny.test/attr/department/value/engineering | scs_claims_eng2 | read | | + Then the response should be successful + Given there is a claims subject entity referenced as "bob_claims" with claims: + """ + {"userName":"bob","department":"marketing"} + """ + When I send a decision request for entity chain "bob_claims" for "read" action on resource "https://claims-deny.test/attr/department/value/engineering" + Then the response should be successful + And I should get a "DENY" decision response From 4968d60a36f23ca29993221a36828213fe43b304 Mon Sep 17 00:00:00 2001 From: Ryan Schumacher Date: Thu, 30 Jul 2026 23:57:28 -0500 Subject: [PATCH 5/5] fix(entityresolution): scope inline claims context to claims entities Signed-off-by: Ryan Schumacher --- .../multi-strategy/registration.go | 5 +- .../multi-strategy/registration_test.go | 55 ++++++++++++++++++ .../multi-strategy/v2/registration.go | 5 +- .../multi-strategy/v2/registration_test.go | 56 +++++++++++++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/service/entityresolution/multi-strategy/registration.go b/service/entityresolution/multi-strategy/registration.go index 8d3ff86c5f..e93889e611 100644 --- a/service/entityresolution/multi-strategy/registration.go +++ b/service/entityresolution/multi-strategy/registration.go @@ -55,6 +55,7 @@ func (ers *ERS) ResolveEntities( continue } + resolveCtx := ctx var claimsMap types.JWTClaims switch entity.GetEntityType().(type) { case *authorization.Entity_Claims: @@ -68,6 +69,7 @@ func (ers *ERS) ResolveEntities( } // Convert to map[string]interface{} claimsMap = claimsStruct.AsMap() + resolveCtx = context.WithValue(ctx, types.JWTClaimsContextKey, claimsMap) } default: entityBytes, err := protojson.Marshal(entity) @@ -81,8 +83,7 @@ func (ers *ERS) ResolveEntities( } // Resolve entity using multi-strategy service - ctxWithClaims := context.WithValue(ctx, types.JWTClaimsContextKey, claimsMap) - result, err := ers.service.ResolveEntity(ctxWithClaims, entityID, claimsMap) + result, err := ers.service.ResolveEntity(resolveCtx, entityID, claimsMap) if err != nil { ers.logger.Error("failed to resolve entity", slog.String("entity_id", entityID), diff --git a/service/entityresolution/multi-strategy/registration_test.go b/service/entityresolution/multi-strategy/registration_test.go index f1f9f8820c..5e1eebf8c0 100644 --- a/service/entityresolution/multi-strategy/registration_test.go +++ b/service/entityresolution/multi-strategy/registration_test.go @@ -100,3 +100,58 @@ func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { t.Fatalf("expected successful resolution, got error payload: %v", result["error"]) } } + +func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { + t.Helper() + + erService, err := NewERS(t.Context(), types.MultiStrategyConfig{ + Providers: map[string]types.ProviderConfig{ + "jwt": { + Type: "claims", + Connection: map[string]interface{}{}, + }, + }, + FailureStrategy: types.FailureStrategyContinue, + MappingStrategies: []types.MappingStrategy{ + { + Name: "claims_passthrough", + Provider: "jwt", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{ + JWTClaims: []types.JWTClaimCondition{{Claim: "userName", Operator: "exists"}}, + }, + OutputMapping: []types.OutputMapping{{SourceClaim: "userName", ClaimName: "username"}}, + }, + }, + }, logger.CreateTestLogger()) + if err != nil { + t.Fatalf("NewERS() error = %v", err) + } + + resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&entityresolution.ResolveEntitiesRequest{ + Entities: []*authorization.Entity{{ + Id: "alice-user-name", + EntityType: &authorization.Entity_UserName{UserName: "alice"}, + }}, + })) + if err != nil { + t.Fatalf("ResolveEntities() error = %v", err) + } + + if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { + t.Fatalf("expected 1 entity representation, got %d", got) + } + + props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() + if len(props) != 1 { + t.Fatalf("expected 1 additional props entry, got %d", len(props)) + } + + result := props[0].AsMap() + if _, hasError := result["error"]; !hasError { + t.Fatalf("expected claims provider to fail without middleware claims for user_name entity, got %v", result) + } + if got := result["entity_id"]; got != "alice-user-name" { + t.Fatalf("expected entity_id alice-user-name, got %v", got) + } +} diff --git a/service/entityresolution/multi-strategy/v2/registration.go b/service/entityresolution/multi-strategy/v2/registration.go index d818e11e01..4d04f77888 100644 --- a/service/entityresolution/multi-strategy/v2/registration.go +++ b/service/entityresolution/multi-strategy/v2/registration.go @@ -64,6 +64,7 @@ func (ers *ERSV2) ResolveEntities( ers.logger.Warn("empty entity ID in request; using generated ID", slog.String("entity_id", entityID)) } + resolveCtx := ctx var claimsMap types.JWTClaims switch entityV2.GetEntityType().(type) { case *entity.Entity_Claims: @@ -77,6 +78,7 @@ func (ers *ERSV2) ResolveEntities( } // Convert to map[string]interface{} claimsMap = claimsStruct.AsMap() + resolveCtx = context.WithValue(ctx, types.JWTClaimsContextKey, claimsMap) } default: entityBytes, err := protojson.Marshal(entityV2) @@ -90,8 +92,7 @@ func (ers *ERSV2) ResolveEntities( } // Resolve entity using multi-strategy service - ctxWithClaims := context.WithValue(ctx, types.JWTClaimsContextKey, claimsMap) - result, err := ers.service.ResolveEntity(ctxWithClaims, entityID, claimsMap) + result, err := ers.service.ResolveEntity(resolveCtx, entityID, claimsMap) if err != nil { ers.logger.Error("failed to resolve entity", slog.String("entity_id", entityID), diff --git a/service/entityresolution/multi-strategy/v2/registration_test.go b/service/entityresolution/multi-strategy/v2/registration_test.go index 83389c38be..4dc82b6a81 100644 --- a/service/entityresolution/multi-strategy/v2/registration_test.go +++ b/service/entityresolution/multi-strategy/v2/registration_test.go @@ -269,3 +269,59 @@ func TestResolveEntities_ClaimsProviderUsesInlineClaimsContext(t *testing.T) { t.Fatalf("expected successful resolution, got error payload: %v", result["error"]) } } + +func TestResolveEntities_UserNameEntityDoesNotSeedClaimsContext(t *testing.T) { + t.Helper() + + erService, err := NewERSV2(t.Context(), types.MultiStrategyConfig{ + Providers: map[string]types.ProviderConfig{ + "jwt": { + Type: "claims", + Connection: map[string]interface{}{}, + }, + }, + FailureStrategy: types.FailureStrategyContinue, + MappingStrategies: []types.MappingStrategy{ + { + Name: "claims_passthrough", + Provider: "jwt", + EntityType: types.EntityTypeSubject, + Conditions: types.StrategyConditions{ + JWTClaims: []types.JWTClaimCondition{{Claim: "userName", Operator: "exists"}}, + }, + OutputMapping: []types.OutputMapping{{SourceClaim: "userName", ClaimName: "username"}}, + }, + }, + }, logger.CreateTestLogger()) + if err != nil { + t.Fatalf("NewERSV2() error = %v", err) + } + + resp, err := erService.ResolveEntities(t.Context(), connect.NewRequest(&ersV2.ResolveEntitiesRequest{ + Entities: []*entity.Entity{{ + EphemeralId: "alice-user-name", + EntityType: &entity.Entity_UserName{UserName: "alice"}, + Category: entity.Entity_CATEGORY_SUBJECT, + }}, + })) + if err != nil { + t.Fatalf("ResolveEntities() error = %v", err) + } + + if got := len(resp.Msg.GetEntityRepresentations()); got != 1 { + t.Fatalf("expected 1 entity representation, got %d", got) + } + + props := resp.Msg.GetEntityRepresentations()[0].GetAdditionalProps() + if len(props) != 1 { + t.Fatalf("expected 1 additional props entry, got %d", len(props)) + } + + result := props[0].AsMap() + if _, hasError := result["error"]; !hasError { + t.Fatalf("expected claims provider to fail without middleware claims for user_name entity, got %v", result) + } + if got := result["entity_id"]; got != "alice-user-name" { + t.Fatalf("expected entity_id alice-user-name, got %v", got) + } +}