diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go index c0e18f92c..f5d5a2cb5 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" "log/slog" - "strconv" + "strings" authz "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/policy" @@ -28,7 +28,7 @@ type ObligationsPolicyDecisionPoint struct { logger *logger.Logger attributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue registeredResourceValuesByFQN map[string]*policy.RegisteredResourceValue - obligationValuesByFQN map[string]*policy.ObligationValue + // When resolving triggered obligations, there are multiple trigger paths: // 1. actions on attributes // 2. actions on attributes within the request context of a specific PEP, driven by PEP idP clientID @@ -54,19 +54,18 @@ func NewObligationsPolicyDecisionPoint( logger: l, attributesByValueFQN: attributesByValueFQN, registeredResourceValuesByFQN: registeredResourceValuesByFQN, - obligationValuesByFQN: make(map[string]*policy.ObligationValue), } simpleTriggered := make(obligationValuesByActionOnAnAttributeValue) clientScopedTriggered := make(map[string]obligationValuesByActionOnAnAttributeValue) + // For every trigger on every value on every obligation definition for _, definition := range allObligations { for _, obligationValue := range definition.GetValues() { - pdp.obligationValuesByFQN[obligationValue.GetFqn()] = obligationValue - for _, trigger := range obligationValue.GetTriggers() { attrValFqn := trigger.GetAttributeValue().GetFqn() actionName := trigger.GetAction().GetName() + // Populate unscoped lookup graph with just actions and attributes alone if len(trigger.GetContext()) == 0 { if _, ok := simpleTriggered[actionName]; !ok { @@ -75,7 +74,7 @@ func NewObligationsPolicyDecisionPoint( simpleTriggered[actionName][attrValFqn] = append(simpleTriggered[actionName][attrValFqn], obligationValue.GetFqn()) } - // If request contexts were provided, PEP client ID was required to scope an obligation value to a PEP, so populate that lookup graph + // If trigger has a request context specified, PEP clientID will scope the obligation value to a specific PEP for _, optionalRequestContext := range trigger.GetContext() { requiredPEPClientID := optionalRequestContext.GetPep().GetClientId() @@ -101,13 +100,92 @@ func NewObligationsPolicyDecisionPoint( pdp.logger.DebugContext( ctx, "created obligations policy decision point", - slog.Int("obligation_values_count", len(pdp.obligationValuesByFQN)), + ) + + pdp.logger.TraceContext( + ctx, + "trigger relationships", + slog.Any("simple", simpleTriggered), + slog.Any("client_scoped", clientScopedTriggered), ) return pdp, nil } -// GetRequiredObligations takes in an action and multiple resources subject to decisioning. +// GetAllTriggeredObligationsAreFulfilled takes in: +// +// 1. resources +// 2. an action being taken +// 3. a decision request context +// 4. the obligation value FQNs a PEP is capable of fulfilling (self-reported) +// +// It will check the action, resources, and decision request context for the obligation values triggered, +// compare the PEP fulfillable obligations against those that have been triggered as required, +// and return whether or not all triggered obligations can be fulfilled along with the set of obligation FQNs +// the PEP must fulfill for each resource in the provided list. +func (p *ObligationsPolicyDecisionPoint) GetAllTriggeredObligationsAreFulfilled( + ctx context.Context, + resources []*authz.Resource, + action *policy.Action, + decisionRequestContext *policy.RequestContext, + pepFulfillableObligationValueFQNs []string, +) (bool, [][]string, error) { + perResource, allTriggered, err := p.getTriggeredObligations(ctx, action, resources, decisionRequestContext) + if err != nil { + return false, nil, err + } + + allFulfilled := p.getAllObligationsAreFulfilled(ctx, action, allTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext) + return allFulfilled, perResource, nil +} + +// getAllObligationsAreFulfilled checks the deduplicated list of triggered obligations against the PEP +// self-reported fulfillable obligations to validate the PEP can fulfill all that were triggered. +// +// While this is a simple check now, enhancements in types of obligations and the fulfillment source of truth +// (such as a PEP registration or centralized config) will add complexity to this validation. The RequestContext +// itself may sometimes contain information that may fulfill the obligation in the future. +func (p *ObligationsPolicyDecisionPoint) getAllObligationsAreFulfilled( + ctx context.Context, + action *policy.Action, + allTriggeredObligationValueFQNs []string, + pepFulfillableObligationValueFQNs []string, + decisionRequestContext *policy.RequestContext, +) bool { + log := loggerWithAttributes(p.logger, strings.ToLower(action.GetName()), decisionRequestContext.GetPep().GetClientId()) + + fulfillable := make(map[string]struct{}) + for _, obligation := range pepFulfillableObligationValueFQNs { + obligation = strings.ToLower(obligation) + fulfillable[obligation] = struct{}{} + } + + var unfulfilled []string + for _, obligated := range allTriggeredObligationValueFQNs { + obligated = strings.ToLower(obligated) + if _, found := fulfillable[obligated]; !found { + unfulfilled = append(unfulfilled, obligated) + } + } + + if len(unfulfilled) > 0 { + log.DebugContext( + ctx, + "found unfulfilled obligations that cannot be fulfilled by PEP", + slog.Any("unfulfilled_obligations", unfulfilled), + ) + return false + } + + log.DebugContext( + ctx, + "all triggered obligations can be fulfilled by PEP", + ) + + return true +} + +// getTriggeredObligations takes in an action and multiple resources subject to decisioning. // // It drills into the resources to find all triggered obligations on each combination of: // 1. action @@ -115,7 +193,7 @@ func NewObligationsPolicyDecisionPoint( // 3. decision request context (at present, strictly any scoped PEP clientID) // // In response, it returns the obligations required per each input resource index and the entire list of deduplicated required obligations -func (p *ObligationsPolicyDecisionPoint) GetRequiredObligations( +func (p *ObligationsPolicyDecisionPoint) getTriggeredObligations( ctx context.Context, action *policy.Action, resources []*authz.Resource, @@ -128,12 +206,8 @@ func (p *ObligationsPolicyDecisionPoint) GetRequiredObligations( allOblValFQNsSeen := make(map[string]struct{}) pepClientID := decisionRequestContext.GetPep().GetClientId() - actionName := action.GetName() - - l := p.logger. - With("action", actionName). - With("pep_client_id", pepClientID). - With("resources_count", strconv.Itoa(len(resources))) + actionName := strings.ToLower(action.GetName()) + log := loggerWithAttributes(p.logger, actionName, pepClientID) // Short-circuit if the requested action and optional scoping clientID are not found within any obligation triggers attrValueFQNsToObligations, triggersOnActionExist := p.simpleTriggerActionsToAttributes[actionName] @@ -142,9 +216,10 @@ func (p *ObligationsPolicyDecisionPoint) GetRequiredObligations( _, triggersOnClientIDExist = clientScoped[actionName] } if !triggersOnActionExist && !triggersOnClientIDExist { - l.DebugContext(ctx, "no triggered obligations found for action", - slog.Any("simple", p.simpleTriggerActionsToAttributes), - slog.Any("client_scoped", p.clientIDScopedTriggerActionsToAttributes), + log.DebugContext( + ctx, + "no triggered obligations found", + slog.Int("resources_count", len(resources)), ) return requiredOblValueFQNsPerResource, nil, nil } @@ -152,10 +227,10 @@ func (p *ObligationsPolicyDecisionPoint) GetRequiredObligations( // Traverse trigger lookup graphs to resolve required obligations for i, resource := range resources { // For each type of resource, drill down within to collect the attribute value FQNs relevant to this action - attrValueFQNs := []string{} + var attrValueFQNs []string switch resource.GetResource().(type) { case *authz.Resource_RegisteredResourceValueFqn: - regResValFQN := resource.GetRegisteredResourceValueFqn() + regResValFQN := strings.ToLower(resource.GetRegisteredResourceValueFqn()) regResValue, ok := p.registeredResourceValuesByFQN[regResValFQN] if !ok { return nil, nil, fmt.Errorf("%w: %s", ErrUnknownRegisteredResourceValue, regResValFQN) @@ -182,6 +257,8 @@ func (p *ObligationsPolicyDecisionPoint) GetRequiredObligations( seenThisResource := make(map[string]struct{}) resourceRequiredOblValueFQNsSet := make([]string, 0) for _, attrValFQN := range attrValueFQNs { + attrValFQN = strings.ToLower(attrValFQN) + if triggeredObligations, someTriggered := attrValueFQNsToObligations[attrValFQN]; someTriggered { for _, oblValFQN := range triggeredObligations { if _, seen := seenThisResource[oblValFQN]; seen { @@ -219,12 +296,23 @@ func (p *ObligationsPolicyDecisionPoint) GetRequiredObligations( requiredOblValueFQNsPerResource[i] = resourceRequiredOblValueFQNsSet } - l.DebugContext( + log.DebugContext( ctx, "found required obligations", - slog.Any("required_obl_values_per_resource", requiredOblValueFQNsPerResource), - slog.Any("required_obligations_across_all_resources", allRequiredOblValueFQNs), + slog.Any("total_required_obligations_deduplicated", allRequiredOblValueFQNs), + ) + log.TraceContext( + ctx, + "obligations per resource", + slog.Any("required_obligations_per_resource", requiredOblValueFQNsPerResource), ) return requiredOblValueFQNsPerResource, allRequiredOblValueFQNs, nil } + +func loggerWithAttributes(log *logger.Logger, actionName, pepClientID string) *logger.Logger { + if pepClientID != "" { + log = log.With("pep_client_id", pepClientID) + } + return log.With("action", strings.ToLower(actionName)) +} diff --git a/service/internal/access/v2/obligations/obligations_pdp_test.go b/service/internal/access/v2/obligations/obligations_pdp_test.go index 18dd4b032..d8111677b 100644 --- a/service/internal/access/v2/obligations/obligations_pdp_test.go +++ b/service/internal/access/v2/obligations/obligations_pdp_test.go @@ -1,6 +1,7 @@ package obligations import ( + "strings" "testing" "github.com/stretchr/testify/suite" @@ -21,9 +22,9 @@ const ( mockObligationFQN3 = "https://example.org/obl/create_obligation/value/create_value" mockObligationFQN4 = "https://example.org/obl/custom_obligation/value/custom_value" - mockRegResValFQN1 = "https://example.org/reg_res/resource1/value/val1" - mockRegResValFQN2 = "https://example.org/reg_res/resource2/value/val2" - mockRegResValFQN3 = "https://example.org/reg_res/resource2/value/val3" + mockRegResValFQN1 = "https://reg_res/resource1/value/val1" + mockRegResValFQN2 = "https://reg_res/resource2/value/val2" + mockRegResValFQN3 = "https://reg_res/resource2/value/val3" mockClientID = "mock-client-id" @@ -42,7 +43,8 @@ var ( type ObligationsPDPSuite struct { suite.Suite - pdp *ObligationsPolicyDecisionPoint + pdp *ObligationsPolicyDecisionPoint + testLogger *logger.Logger } func Test_ObligationsPDPSuite(t *testing.T) { @@ -156,11 +158,13 @@ func (s *ObligationsPDPSuite) SetupSuite() { }, } + s.testLogger = logger.CreateTestLogger() + // Create a new PDP instance var err error s.pdp, err = NewObligationsPolicyDecisionPoint( s.T().Context(), - logger.CreateTestLogger(), + s.testLogger, attributesByValueFQN, registeredResourceValuesByFQN, allObligations, @@ -187,8 +191,6 @@ func (s *ObligationsPDPSuite) Test_NewObligationsPolicyDecisionPoint_Success() { s.NotNil(pdp.logger) s.Equal(attributesByValueFQN, pdp.attributesByValueFQN) s.Empty(pdp.registeredResourceValuesByFQN) - s.Len(pdp.obligationValuesByFQN, 1) - s.Contains(pdp.obligationValuesByFQN, mockObligationFQN1) s.NotNil(pdp.simpleTriggerActionsToAttributes) s.NotNil(pdp.clientIDScopedTriggerActionsToAttributes) } @@ -208,8 +210,6 @@ func (s *ObligationsPDPSuite) Test_NewObligationsPolicyDecisionPoint_WithClientS s.Require().NoError(err) s.NotNil(pdp) - s.Len(pdp.obligationValuesByFQN, 1) - s.Contains(pdp.obligationValuesByFQN, mockObligationFQN2) s.Contains(pdp.clientIDScopedTriggerActionsToAttributes, mockClientID) s.Contains(pdp.clientIDScopedTriggerActionsToAttributes[mockClientID], actionNameRead) s.Contains(pdp.clientIDScopedTriggerActionsToAttributes[mockClientID][actionNameRead], mockAttrValFQN2) @@ -270,12 +270,11 @@ func (s *ObligationsPDPSuite) Test_NewObligationsPolicyDecisionPoint_EmptyObliga s.Require().NoError(err) s.NotNil(pdp) - s.Empty(pdp.obligationValuesByFQN) s.Empty(pdp.simpleTriggerActionsToAttributes) s.Empty(pdp.clientIDScopedTriggerActionsToAttributes) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_NoObligationsTriggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_NoObligationsTriggered() { type args struct { action *policy.Action resources []*authz.Resource @@ -318,7 +317,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_NoObligationsTriggered } for _, tt := range tests { s.T().Run(tt.name, func(t *testing.T) { - perResource, all, err := s.pdp.GetRequiredObligations(t.Context(), tt.args.action, tt.args.resources, tt.args.decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(t.Context(), tt.args.action, tt.args.resources, tt.args.decisionRequestContext) s.Require().NoError(err) s.Len(perResource, len(tt.args.resources)) @@ -330,7 +329,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_NoObligationsTriggered } } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_SimpleObligation_NoRequestContextPEP_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_SimpleObligation_NoRequestContextPEP_Triggered() { resources := []*authz.Resource{ { Resource: &authz.Resource_AttributeValues_{ @@ -342,14 +341,14 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_SimpleObligation_NoReq } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Equal([][]string{{mockObligationFQN1}}, perResource) s.Equal([]string{mockObligationFQN1}, all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_ClientScopedObligation_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_ClientScopedObligation_Triggered() { resources := []*authz.Resource{ { Resource: &authz.Resource_AttributeValues_{ @@ -366,14 +365,14 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_ClientScopedObligation } // Found when client provided and matching - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Equal([][]string{{mockObligationFQN2}}, perResource) s.Equal([]string{mockObligationFQN2}, all) // Not found when client not provided decisionRequestContext.Pep.ClientId = "" - perResource, all, err = s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err = s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) for _, r := range perResource { s.Empty(r) @@ -381,7 +380,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_ClientScopedObligation s.Empty(all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedObligations_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_MixedObligations_Triggered() { resources := []*authz.Resource{ { Resource: &authz.Resource_AttributeValues_{ @@ -411,7 +410,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedObligations_Trigg }, } - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) // Obligations in order of resources: unscoped, scoped, both s.Equal([][]string{{mockObligationFQN1}, {mockObligationFQN2}, {mockObligationFQN1, mockObligationFQN2}}, perResource) @@ -419,7 +418,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedObligations_Trigg s.ElementsMatch([]string{mockObligationFQN1, mockObligationFQN2}, all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_UnknownRegisteredResourceValue_Fails() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_UnknownRegisteredResourceValue_Fails() { badRegResValFQN := "https://reg_res/not_found_reg_res" resources := []*authz.Resource{ { @@ -430,7 +429,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_UnknownRegisteredResou } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().Error(err) s.Require().ErrorIs(err, ErrUnknownRegisteredResourceValue) s.Contains(err.Error(), badRegResValFQN, "error should contain the FQN that was not found") @@ -438,7 +437,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_UnknownRegisteredResou s.Empty(all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CreateAction_SimpleObligation_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_CreateAction_SimpleObligation_Triggered() { resources := []*authz.Resource{ { Resource: &authz.Resource_AttributeValues_{ @@ -450,14 +449,14 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CreateAction_SimpleObl } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) s.Require().NoError(err) s.Equal([][]string{{mockObligationFQN3}}, perResource) s.Equal([]string{mockObligationFQN3}, all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CreateAction_NoObligationsTriggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_CreateAction_NoObligationsTriggered() { // Test that 'create' action doesn't trigger 'read' obligations resources := []*authz.Resource{ { @@ -474,7 +473,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CreateAction_NoObligat }, } - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) s.Require().NoError(err) // No create obligations exist for mockAttrValFQN2, so nothing should be triggered @@ -485,7 +484,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CreateAction_NoObligat s.Empty(all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_ReadVsCreateAction_DifferentObligationsTriggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_ReadVsCreateAction_DifferentObligationsTriggered() { // Test the same resource with both actions to verify action-specific filtering resources := []*authz.Resource{ { @@ -499,13 +498,13 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_ReadVsCreateAction_Dif decisionRequestContext := emptyDecisionRequestContext // Test with 'read' action - should trigger read obligation - perResourceRead, allRead, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResourceRead, allRead, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Equal([][]string{{mockObligationFQN1}}, perResourceRead) s.Equal([]string{mockObligationFQN1}, allRead) // Test with 'create' action - should trigger create obligation - perResourceCreate, allCreate, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + perResourceCreate, allCreate, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) s.Require().NoError(err) s.Equal([][]string{{mockObligationFQN3}}, perResourceCreate) s.Equal([]string{mockObligationFQN3}, allCreate) @@ -514,7 +513,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_ReadVsCreateAction_Dif s.NotEqual(allRead, allCreate) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_ReadAction_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_RegisteredResource_ReadAction_Triggered() { resources := []*authz.Resource{ { Resource: &authz.Resource_RegisteredResourceValueFqn{ @@ -524,7 +523,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Rea } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 1, "should have obligations for exactly one resource") @@ -534,7 +533,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Rea s.Contains(all, mockObligationFQN1) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_CreateAction_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_RegisteredResource_CreateAction_Triggered() { resources := []*authz.Resource{ { Resource: &authz.Resource_RegisteredResourceValueFqn{ @@ -544,7 +543,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cre } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 1, "should have obligations for exactly one resource") @@ -554,7 +553,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cre s.Contains(all, mockObligationFQN3) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_NoCreateAction_NoObligationsTriggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_RegisteredResource_NoCreateAction_NoObligationsTriggered() { // Use mockRegResValFQN2 which only has read action, not create resources := []*authz.Resource{ { @@ -569,7 +568,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_NoC }, } - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) s.Require().NoError(err) s.Len(perResource, len(resources)) @@ -579,7 +578,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_NoC s.Empty(all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_ClientScoped_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_RegisteredResource_ClientScoped_Triggered() { // Use mockRegResValFQN2 which maps to mockAttrValFQN2 (has client-scoped read obligation) resources := []*authz.Resource{ { @@ -594,7 +593,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cli }, } - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 1, "should have obligations for exactly one resource") @@ -605,7 +604,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cli // Nothing should be triggered if no client decisionRequestContext.Pep.ClientId = "" - perResource, all, err = s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err = s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Len(perResource, len(resources)) for _, r := range perResource { @@ -614,7 +613,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cli s.Empty(all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedResources_RegisteredAndDirect_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_MixedResources_RegisteredAndDirect_Triggered() { // Mix registered resource and direct attribute values resources := []*authz.Resource{ { @@ -636,7 +635,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedResources_Registe }, } - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 2, "should have obligations for exactly two resources") @@ -654,7 +653,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedResources_Registe s.ElementsMatch([]string{mockObligationFQN1, mockObligationFQN2}, all) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_CustomAction_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_RegisteredResource_CustomAction_Triggered() { // Use mockRegResValFQN3 which has custom action and should trigger mockObligationFQN4 resources := []*authz.Resource{ { @@ -665,7 +664,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cus } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 1, "should have obligations for exactly one resource") @@ -680,7 +679,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cus ClientId: mockClientID, }, } - perResource, all, err = s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + perResource, all, err = s.pdp.getTriggeredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 1, "should have obligations for exactly one resource") @@ -690,7 +689,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cus s.Contains(all, mockObligationFQN4) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_CustomAction_WrongAction_NoObligationsTriggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_RegisteredResource_CustomAction_WrongAction_NoObligationsTriggered() { // Use mockRegResValFQN3 (has custom action) but call with read action - should trigger nothing resources := []*authz.Resource{ { @@ -701,7 +700,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cus } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 1, "should have results for exactly one resource") @@ -709,7 +708,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_Cus s.Empty(all, "no obligations should be triggered globally") } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_RegisteredResource_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_CustomAction_RegisteredResource_Triggered() { // Test custom action with registered resource resources := []*authz.Resource{ { @@ -720,7 +719,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_Registere } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 1, "should have obligations for exactly one resource") @@ -730,7 +729,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_Registere s.Contains(all, mockObligationFQN4) } -func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_MixedResources_Triggered() { +func (s *ObligationsPDPSuite) Test_getTriggeredObligations_CustomAction_MixedResources_Triggered() { // Test custom action with mixed resource types resources := []*authz.Resource{ // Direct attribute that triggers custom obligation @@ -756,7 +755,7 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_MixedReso } decisionRequestContext := emptyDecisionRequestContext - perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + perResource, all, err := s.pdp.getTriggeredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) s.Require().NoError(err) s.Require().Len(perResource, 3, "should have results for exactly three resources") @@ -777,15 +776,259 @@ func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_MixedReso s.Contains(all, mockObligationFQN4) } -func (s *ObligationsPDPSuite) createAttributesByValueFQN(attrValFQN, attrName string) map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue { - return map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ - attrValFQN: { - Attribute: &policy.Attribute{Name: attrName}, - Value: &policy.Value{Fqn: attrValFQN}, +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_MoreFulfilledThanTriggered() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_ExactMatch() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN2, mockObligationFQN1} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_CasingMismatchFQNs() { + allTriggeredObligationValueFQNs := []string{strings.ToUpper(mockObligationFQN1), mockObligationFQN2} + pepFulfillableObligationValueFQNs := []string{strings.ToUpper(mockObligationFQN2), mockObligationFQN1} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_MissingObligation() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN3} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN1} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + + s.False(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_EmptyTriggered() { + allTriggeredObligationValueFQNs := []string{} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_EmptyFulfillable() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1} + pepFulfillableObligationValueFQNs := []string{} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + + s.False(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_BothEmpty() { + allTriggeredObligationValueFQNs := []string{} + pepFulfillableObligationValueFQNs := []string{} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_SingleObligation_Fulfilled() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN1} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_SingleObligation_NotFulfilled() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN3} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN2} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + + s.False(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_DuplicateTriggered() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN1, mockObligationFQN2} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_DuplicateFulfillable() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN1, mockObligationFQN2, mockObligationFQN2} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_AllObligations_Fulfilled() { + allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3, mockObligationFQN4} + pepFulfillableObligationValueFQNs := []string{mockObligationFQN4, mockObligationFQN3, mockObligationFQN2, mockObligationFQN1} + + fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + s.True(fulfilled) +} + +func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke() { + type args struct { + resources []*authz.Resource + action *policy.Action + decisionRequestContext *policy.RequestContext + pepFulfillable []string + } + tests := []struct { + name string + args args + wantAllFulfilled bool + wantPerResource [][]string + }{ + { + name: "fulfilled - attributes", + args: args{ + action: actionRead, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1}, + }, + }, + }, + }, + pepFulfillable: []string{mockObligationFQN1}, + }, + wantAllFulfilled: true, + wantPerResource: [][]string{{mockObligationFQN1}}, + }, + { + name: "fulfilled - registered resource", + args: args{ + action: actionRead, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN1, + }, + }, + }, + pepFulfillable: []string{mockObligationFQN1}, + }, + wantAllFulfilled: true, + wantPerResource: [][]string{{mockObligationFQN1}}, + }, + { + name: "fulfilled - registered resource client scoped", + args: args{ + action: actionRead, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN2, + }, + }, + }, + decisionRequestContext: &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + }, + pepFulfillable: []string{mockObligationFQN2}, + }, + wantAllFulfilled: true, + wantPerResource: [][]string{{mockObligationFQN2}}, }, + { + name: "fulfilled - casing mismatches", + args: args{ + action: &policy.Action{ + Name: strings.ToUpper(actionRead.GetName()), + }, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{strings.ToUpper(mockAttrValFQN1)}, + }, + }, + }, + }, + pepFulfillable: []string{strings.ToUpper(mockObligationFQN1)}, + }, + wantAllFulfilled: true, + wantPerResource: [][]string{{mockObligationFQN1}}, + }, + { + name: "unfulfilled - attributes", + args: args{ + action: actionRead, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1}, + }, + }, + }, + }, + pepFulfillable: []string{mockObligationFQN2}, + }, + wantAllFulfilled: false, + wantPerResource: [][]string{{mockObligationFQN1}}, + }, + { + name: "unfulfilled - registered resource", + args: args{ + action: actionRead, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN1, + }, + }, + }, + pepFulfillable: []string{mockObligationFQN2}, + }, + wantAllFulfilled: false, + wantPerResource: [][]string{{mockObligationFQN1}}, + }, + { + name: "no obligations triggered", + args: args{ + action: actionRead, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN3}, + }, + }, + }, + }, + }, + wantAllFulfilled: true, + wantPerResource: [][]string{{}}, + }, + } + for _, tt := range tests { + s.T().Run(tt.name, func(t *testing.T) { + gotAllFulfilled, gotPerResource, err := s.pdp.GetAllTriggeredObligationsAreFulfilled(t.Context(), tt.args.resources, tt.args.action, tt.args.decisionRequestContext, tt.args.pepFulfillable) + s.Require().NoError(err) + s.Equal(tt.wantAllFulfilled, gotAllFulfilled, tt.name) + s.Equal(tt.wantPerResource, gotPerResource, tt.name) + }) } } +// +// Test suite helpers +// + func (s *ObligationsPDPSuite) createObligation(oblFQN, attrValFQN, clientID string, action *policy.Action) *policy.Obligation { trigger := &policy.ObligationTrigger{ Action: action, @@ -811,3 +1054,12 @@ func (s *ObligationsPDPSuite) createObligation(oblFQN, attrValFQN, clientID stri }, } } + +func (s *ObligationsPDPSuite) createAttributesByValueFQN(attrValFQN, attrName string) map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue { + return map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + attrValFQN: { + Attribute: &policy.Attribute{Name: attrName}, + Value: &policy.Value{Fqn: attrValFQN}, + }, + } +}