diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 791502db23..468f225aa9 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -701,7 +701,7 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition() { result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValues, tc.definition) if tc.expectError { - s.Error(err) + s.Require().Error(err) } else { s.Require().NoError(err) s.NotNil(result) @@ -933,7 +933,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { ) if tc.expectError { - s.Error(err) + s.Require().Error(err) } else { s.Require().NoError(err) s.NotNil(decision) diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go new file mode 100644 index 0000000000..b0c8c6aa73 --- /dev/null +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -0,0 +1,232 @@ +package obligations + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/service/logger" +) + +var ( + ErrEmptyPEPClientID = errors.New("trigger request context is optional but must contain PEP client ID") + ErrUnknownRegisteredResourceValue = errors.New("unknown registered resource value") + ErrUnsupportedResourceType = errors.New("unsupported resource type") +) + +// A graph of action names to attribute value FQNs to lists of obligation value FQNs +// i.e. read : https://example.org/attr/attr1/value/val1 : [https://example.org/obl/some_obligation/value/some_value] +type obligationValuesByActionOnAnAttributeValue map[string]map[string][]string + +//nolint:revive // There are a growing number of PDP types, so keep the naming verbose +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 + // + // Both are able to be pre-computed from policy into a graph data structure so an actual PDP + // trigger check can traverse in fastest possible time complexity. + // + // read : attrValFQN : []string{obl1} + simpleTriggerActionsToAttributes obligationValuesByActionOnAnAttributeValue + // pep-client : read : attrValFQN : []string{obl2} + // other-pep-client : read : attrValFQN : []string{obl2,obl3} + clientIDScopedTriggerActionsToAttributes map[string]obligationValuesByActionOnAnAttributeValue +} + +func NewObligationsPolicyDecisionPoint( + ctx context.Context, + l *logger.Logger, + attributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, + registeredResourceValuesByFQN map[string]*policy.RegisteredResourceValue, + allObligations []*policy.Obligation, +) (*ObligationsPolicyDecisionPoint, error) { + pdp := &ObligationsPolicyDecisionPoint{ + logger: l, + attributesByValueFQN: attributesByValueFQN, + registeredResourceValuesByFQN: registeredResourceValuesByFQN, + obligationValuesByFQN: make(map[string]*policy.ObligationValue), + } + + simpleTriggered := make(obligationValuesByActionOnAnAttributeValue) + clientScopedTriggered := make(map[string]obligationValuesByActionOnAnAttributeValue) + + 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 { + simpleTriggered[actionName] = make(map[string][]string) + } + 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 + for _, optionalRequestContext := range trigger.GetContext() { + requiredPEPClientID := optionalRequestContext.GetPep().GetClientId() + + if requiredPEPClientID == "" { + return nil, ErrEmptyPEPClientID + } + if _, ok := clientScopedTriggered[requiredPEPClientID]; !ok { + clientScopedTriggered[requiredPEPClientID] = make(obligationValuesByActionOnAnAttributeValue) + } + if _, ok := clientScopedTriggered[requiredPEPClientID][actionName]; !ok { + clientScopedTriggered[requiredPEPClientID][actionName] = make(map[string][]string) + } + clientScopedTriggered[requiredPEPClientID][actionName][attrValFqn] = append(clientScopedTriggered[requiredPEPClientID][actionName][attrValFqn], obligationValue.GetFqn()) + } + } + } + } + + // Store lookup resolution graphs in state for the duration of the PDP + pdp.clientIDScopedTriggerActionsToAttributes = clientScopedTriggered + pdp.simpleTriggerActionsToAttributes = simpleTriggered + + pdp.logger.DebugContext( + ctx, + "created obligations policy decision point", + slog.Int("obligation_values_count", len(pdp.obligationValuesByFQN)), + ) + + return pdp, nil +} + +// GetRequiredObligations 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 +// 2. attribute value +// 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( + ctx context.Context, + action *policy.Action, + resources []*authz.Resource, + decisionRequestContext *policy.RequestContext, +) ([][]string, []string, error) { + // Required obligations per resource of a given index + requiredOblValueFQNsPerResource := make([][]string, len(resources)) + // Set of required obligations across all resources + var allRequiredOblValueFQNs []string + 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))) + + // Short-circuit if the requested action and optional scoping clientID are not found within any obligation triggers + attrValueFQNsToObligations, triggersOnActionExist := p.simpleTriggerActionsToAttributes[actionName] + clientScoped, triggersOnClientIDExist := p.clientIDScopedTriggerActionsToAttributes[pepClientID] + if triggersOnClientIDExist { + _, 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), + ) + return requiredOblValueFQNsPerResource, nil, nil + } + + // 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{} + switch resource.GetResource().(type) { + case *authz.Resource_RegisteredResourceValueFqn: + regResValFQN := resource.GetRegisteredResourceValueFqn() + regResValue, ok := p.registeredResourceValuesByFQN[regResValFQN] + if !ok { + return nil, nil, fmt.Errorf("%w: %s", ErrUnknownRegisteredResourceValue, regResValFQN) + } + + // Check the action-attribute-values associated with a Registered Resource Value for a match to the request action + for _, aav := range regResValue.GetActionAttributeValues() { + aavActionName := aav.GetAction().GetName() + attrValFQN := aav.GetAttributeValue().GetFqn() + if aavActionName != actionName { + continue + } + attrValueFQNs = append(attrValueFQNs, attrValFQN) + } + + case *authz.Resource_AttributeValues_: + attrValueFQNs = append(attrValueFQNs, resource.GetAttributeValues().GetFqns()...) + + default: + return nil, nil, fmt.Errorf("%w: %T", ErrUnsupportedResourceType, resource) + } + + // With list of attribute values for the resource, traverse each lookup graph to resolve the Set of required obligations + seenThisResource := make(map[string]struct{}) + resourceRequiredOblValueFQNsSet := make([]string, 0) + for _, attrValFQN := range attrValueFQNs { + if triggeredObligations, someTriggered := attrValueFQNsToObligations[attrValFQN]; someTriggered { + for _, oblValFQN := range triggeredObligations { + if _, seen := seenThisResource[oblValFQN]; seen { + continue + } + // Update set of obligations triggered for this specific resource + seenThisResource[oblValFQN] = struct{}{} + resourceRequiredOblValueFQNsSet = append(resourceRequiredOblValueFQNsSet, oblValFQN) + + // Update global set tracking those triggered across all resources + if _, seen := allOblValFQNsSeen[oblValFQN]; !seen { + allOblValFQNsSeen[oblValFQN] = struct{}{} + allRequiredOblValueFQNs = append(allRequiredOblValueFQNs, oblValFQN) + } + } + } + + if triggeredObligations, someTriggered := p.clientIDScopedTriggerActionsToAttributes[pepClientID][actionName][attrValFQN]; someTriggered { + for _, oblValFQN := range triggeredObligations { + if _, seen := seenThisResource[oblValFQN]; seen { + continue + } + // Update set of obligations triggered for this specific resource + seenThisResource[oblValFQN] = struct{}{} + resourceRequiredOblValueFQNsSet = append(resourceRequiredOblValueFQNsSet, oblValFQN) + + // Update global set tracking those triggered across all resources + if _, seen := allOblValFQNsSeen[oblValFQN]; !seen { + allOblValFQNsSeen[oblValFQN] = struct{}{} + allRequiredOblValueFQNs = append(allRequiredOblValueFQNs, oblValFQN) + } + } + } + } + requiredOblValueFQNsPerResource[i] = resourceRequiredOblValueFQNsSet + } + + l.DebugContext( + ctx, + "found required obligations", + slog.Any("required_obl_values_per_resource", requiredOblValueFQNsPerResource), + slog.Any("required_obligations_across_all_resources", allRequiredOblValueFQNs), + ) + + return requiredOblValueFQNsPerResource, allRequiredOblValueFQNs, nil +} + +// TODO: pdp.GetObligationsFulfilled? diff --git a/service/internal/access/v2/obligations/obligations_pdp_test.go b/service/internal/access/v2/obligations/obligations_pdp_test.go new file mode 100644 index 0000000000..18dd4b032d --- /dev/null +++ b/service/internal/access/v2/obligations/obligations_pdp_test.go @@ -0,0 +1,813 @@ +package obligations + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/policy" + attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/service/logger" +) + +const ( + mockAttrValFQN1 = "https://example.org/attr/attr1/value/val1" + mockAttrValFQN2 = "https://example.org/attr/attr2/value/val2" + mockAttrValFQN3 = "https://example.org/attr/attr2/value/val3" + + mockObligationFQN1 = "https://example.org/obl/some_obligation/value/some_value" + mockObligationFQN2 = "https://example.org/obl/another_obligation/value/another_value" + 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" + + mockClientID = "mock-client-id" + + actionNameRead = "read" + actionNameCreate = "create" + actionNameCustom = "custom_action" +) + +var ( + actionRead = &policy.Action{Name: actionNameRead} + actionCreate = &policy.Action{Name: actionNameCreate} + actionCustom = &policy.Action{Name: actionNameCustom} + + emptyDecisionRequestContext = &policy.RequestContext{} +) + +type ObligationsPDPSuite struct { + suite.Suite + pdp *ObligationsPolicyDecisionPoint +} + +func Test_ObligationsPDPSuite(t *testing.T) { + suite.Run(t, new(ObligationsPDPSuite)) +} + +func (s *ObligationsPDPSuite) SetupSuite() { + // Mock attributes + attributesByValueFQN := map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + mockAttrValFQN1: { + Attribute: &policy.Attribute{Name: "attr1"}, + Value: &policy.Value{Fqn: mockAttrValFQN1}, + }, + mockAttrValFQN2: { + Attribute: &policy.Attribute{Name: "attr2"}, + Value: &policy.Value{Fqn: mockAttrValFQN2}, + }, + mockAttrValFQN3: { + Attribute: &policy.Attribute{Name: "attr2"}, + Value: &policy.Value{Fqn: mockAttrValFQN3}, + }, + } + + // Mock registered resources + registeredResourceValuesByFQN := map[string]*policy.RegisteredResourceValue{ + mockRegResValFQN1: { + Value: "val1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: actionRead, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN1}, + }, + { + Action: actionCreate, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN1}, + }, + }, + }, + mockRegResValFQN2: { + Value: "val2", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: actionRead, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN2}, + }, + }, + }, + mockRegResValFQN3: { + Value: "val3", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Action: actionCustom, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN3}, + }, + }, + }, + } + + // Mock obligations + allObligations := []*policy.Obligation{ + { + Values: []*policy.ObligationValue{ + // No client PEP scope - triggered by 'read' action + { + Fqn: mockObligationFQN1, + Triggers: []*policy.ObligationTrigger{ + { + Action: actionRead, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN1}, + }, + }, + }, + // Scoped to the mockClientID PEP - triggered by 'read' action + { + Fqn: mockObligationFQN2, + Triggers: []*policy.ObligationTrigger{ + { + Action: actionRead, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN2}, + Context: []*policy.RequestContext{ + { + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + }, + }, + }, + }, + }, + // No client PEP scope - triggered by 'create' action + { + Fqn: mockObligationFQN3, + Triggers: []*policy.ObligationTrigger{ + { + Action: actionCreate, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN1}, + }, + }, + }, + // No client PEP scope - triggered by 'custom' action + { + Fqn: mockObligationFQN4, + Triggers: []*policy.ObligationTrigger{ + { + Action: actionCustom, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN3}, + }, + }, + }, + }, + }, + } + + // Create a new PDP instance + var err error + s.pdp, err = NewObligationsPolicyDecisionPoint( + s.T().Context(), + logger.CreateTestLogger(), + attributesByValueFQN, + registeredResourceValuesByFQN, + allObligations, + ) + s.Require().NoError(err) +} + +func (s *ObligationsPDPSuite) Test_NewObligationsPolicyDecisionPoint_Success() { + attributesByValueFQN := s.createAttributesByValueFQN(mockAttrValFQN1, "attr1") + var noClientID string + var noRegisteredResources map[string]*policy.RegisteredResourceValue + allObligations := []*policy.Obligation{s.createObligation(mockObligationFQN1, mockAttrValFQN1, noClientID, actionRead)} + + pdp, err := NewObligationsPolicyDecisionPoint( + s.T().Context(), + logger.CreateTestLogger(), + attributesByValueFQN, + noRegisteredResources, + allObligations, + ) + + s.Require().NoError(err) + s.NotNil(pdp) + 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) +} + +func (s *ObligationsPDPSuite) Test_NewObligationsPolicyDecisionPoint_WithClientScoped() { + attributesByValueFQN := s.createAttributesByValueFQN(mockAttrValFQN2, "attr2") + allObligations := []*policy.Obligation{s.createObligation(mockObligationFQN2, mockAttrValFQN2, mockClientID, actionRead)} + var noRegisteredResources map[string]*policy.RegisteredResourceValue + + pdp, err := NewObligationsPolicyDecisionPoint( + s.T().Context(), + logger.CreateTestLogger(), + attributesByValueFQN, + noRegisteredResources, + allObligations, + ) + + 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) +} + +func (s *ObligationsPDPSuite) Test_NewObligationsPolicyDecisionPoint_EmptyClientID_Fails() { + attributesByValueFQN := s.createAttributesByValueFQN(mockAttrValFQN1, "attr1") + var noRegisteredResources map[string]*policy.RegisteredResourceValue + + // Create obligation with empty client ID using special case + allObligations := []*policy.Obligation{ + { + Values: []*policy.ObligationValue{ + { + Fqn: mockObligationFQN1, + Triggers: []*policy.ObligationTrigger{ + { + Action: actionRead, + AttributeValue: &policy.Value{Fqn: mockAttrValFQN1}, + Context: []*policy.RequestContext{ + { + Pep: &policy.PolicyEnforcementPoint{ + ClientId: "", + }, + }, + }, + }, + }, + }, + }, + }, + } + + pdp, err := NewObligationsPolicyDecisionPoint( + s.T().Context(), + logger.CreateTestLogger(), + attributesByValueFQN, + noRegisteredResources, + allObligations, + ) + + s.Require().Error(err) + s.Require().ErrorIs(err, ErrEmptyPEPClientID) + s.Nil(pdp) +} + +func (s *ObligationsPDPSuite) Test_NewObligationsPolicyDecisionPoint_EmptyObligations() { + attributesByValueFQN := s.createAttributesByValueFQN(mockAttrValFQN1, "attr1") + var noRegisteredResources map[string]*policy.RegisteredResourceValue + + pdp, err := NewObligationsPolicyDecisionPoint( + s.T().Context(), + logger.CreateTestLogger(), + attributesByValueFQN, + noRegisteredResources, + []*policy.Obligation{}, + ) + + 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() { + type args struct { + action *policy.Action + resources []*authz.Resource + decisionRequestContext *policy.RequestContext + } + tests := []struct { + name string + args args + }{ + { + name: "no obligation triggered by known but unobligated attribute value", + args: args{ + action: actionRead, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN3}, + }, + }, + }, + }, + }, + }, + { + name: "no obligation triggered by unobligated action", + args: args{ + action: &policy.Action{Name: "random-action-name"}, + resources: []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1}, + }, + }, + }, + }, + }, + }, + } + 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) + s.Require().NoError(err) + s.Len(perResource, len(tt.args.resources)) + + for _, r := range perResource { + s.Empty(r) + } + s.Empty(all) + }) + } +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_SimpleObligation_NoRequestContextPEP_Triggered() { + resources := []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1}, + }, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(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() { + resources := []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN2}, + }, + }, + }, + } + decisionRequestContext := &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + + // Found when client provided and matching + perResource, all, err := s.pdp.GetRequiredObligations(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) + s.Require().NoError(err) + for _, r := range perResource { + s.Empty(r) + } + s.Empty(all) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedObligations_Triggered() { + resources := []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1}, + }, + }, + }, + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN2}, + }, + }, + }, + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1, mockAttrValFQN2}, + }, + }, + }, + } + decisionRequestContext := &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + + perResource, all, err := s.pdp.GetRequiredObligations(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) + // Deduplicated obligations + s.ElementsMatch([]string{mockObligationFQN1, mockObligationFQN2}, all) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_UnknownRegisteredResourceValue_Fails() { + badRegResValFQN := "https://reg_res/not_found_reg_res" + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: badRegResValFQN, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(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") + s.Empty(perResource) + s.Empty(all) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CreateAction_SimpleObligation_Triggered() { + resources := []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1}, + }, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(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() { + // Test that 'create' action doesn't trigger 'read' obligations + resources := []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN2}, + }, + }, + }, + } + decisionRequestContext := &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + + s.Require().NoError(err) + // No create obligations exist for mockAttrValFQN2, so nothing should be triggered + s.Len(perResource, len(resources)) + for _, r := range perResource { + s.Empty(r) + } + s.Empty(all) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_ReadVsCreateAction_DifferentObligationsTriggered() { + // Test the same resource with both actions to verify action-specific filtering + resources := []*authz.Resource{ + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN1}, + }, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + // Test with 'read' action - should trigger read obligation + perResourceRead, allRead, err := s.pdp.GetRequiredObligations(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) + s.Require().NoError(err) + s.Equal([][]string{{mockObligationFQN3}}, perResourceCreate) + s.Equal([]string{mockObligationFQN3}, allCreate) + + // Verify the obligations are different + s.NotEqual(allRead, allCreate) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_ReadAction_Triggered() { + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN1, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 1, "should have obligations for exactly one resource") + s.Require().Len(perResource[0], 1, "should have exactly one obligation for the resource") + s.Equal(mockObligationFQN1, perResource[0][0]) + s.Require().Len(all, 1, "should have exactly one obligation total") + s.Contains(all, mockObligationFQN1) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_CreateAction_Triggered() { + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN1, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 1, "should have obligations for exactly one resource") + s.Require().Len(perResource[0], 1, "should have exactly one obligation for the resource") + s.Equal(mockObligationFQN3, perResource[0][0]) + s.Require().Len(all, 1, "should have exactly one obligation total") + s.Contains(all, mockObligationFQN3) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_NoCreateAction_NoObligationsTriggered() { + // Use mockRegResValFQN2 which only has read action, not create + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN2, + }, + }, + } + decisionRequestContext := &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCreate, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Len(perResource, len(resources)) + for _, r := range perResource { + s.Empty(r, "no obligations should be triggered for create action on read-only registered resource") + } + s.Empty(all) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_ClientScoped_Triggered() { + // Use mockRegResValFQN2 which maps to mockAttrValFQN2 (has client-scoped read obligation) + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN2, + }, + }, + } + decisionRequestContext := &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 1, "should have obligations for exactly one resource") + s.Require().Len(perResource[0], 1, "should have exactly one obligation for the resource") + s.Equal(mockObligationFQN2, perResource[0][0]) + s.Require().Len(all, 1, "should have exactly one obligation total") + s.Contains(all, mockObligationFQN2) + + // Nothing should be triggered if no client + decisionRequestContext.Pep.ClientId = "" + perResource, all, err = s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + s.Require().NoError(err) + s.Len(perResource, len(resources)) + for _, r := range perResource { + s.Empty(r, "no obligations should be triggered for create action on read-only registered resource") + } + s.Empty(all) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_MixedResources_RegisteredAndDirect_Triggered() { + // Mix registered resource and direct attribute values + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN1, + }, + }, + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN2}, + }, + }, + }, + } + decisionRequestContext := &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 2, "should have obligations for exactly two resources") + + // First resource (registered resource mapping to mockAttrValFQN1) -> mockObligationFQN1 + s.Require().Len(perResource[0], 1, "first resource should have exactly one obligation") + s.Equal(mockObligationFQN1, perResource[0][0]) + + // Second resource (direct attribute mockAttrValFQN2 with client scoping) -> mockObligationFQN2 + s.Require().Len(perResource[1], 1, "second resource should have exactly one obligation") + s.Equal(mockObligationFQN2, perResource[1][0]) + + // Should have both obligations in total + s.Require().Len(all, 2, "should have exactly two obligations total") + s.ElementsMatch([]string{mockObligationFQN1, mockObligationFQN2}, all) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_CustomAction_Triggered() { + // Use mockRegResValFQN3 which has custom action and should trigger mockObligationFQN4 + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN3, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 1, "should have obligations for exactly one resource") + s.Require().Len(perResource[0], 1, "should have exactly one obligation for the resource") + s.Equal(mockObligationFQN4, perResource[0][0]) + s.Require().Len(all, 1, "should have exactly one obligation total") + s.Contains(all, mockObligationFQN4) + + // Same result even if a client is provided + decisionRequestContext = &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + perResource, all, err = s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 1, "should have obligations for exactly one resource") + s.Require().Len(perResource[0], 1, "should have exactly one obligation for the resource") + s.Equal(mockObligationFQN4, perResource[0][0]) + s.Require().Len(all, 1, "should have exactly one obligation total") + s.Contains(all, mockObligationFQN4) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_RegisteredResource_CustomAction_WrongAction_NoObligationsTriggered() { + // Use mockRegResValFQN3 (has custom action) but call with read action - should trigger nothing + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN3, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionRead, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 1, "should have results for exactly one resource") + s.Empty(perResource[0], "no obligations should be triggered for read action on resource that only has custom action mapping") + s.Empty(all, "no obligations should be triggered globally") +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_RegisteredResource_Triggered() { + // Test custom action with registered resource + resources := []*authz.Resource{ + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN3, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 1, "should have obligations for exactly one resource") + s.Require().Len(perResource[0], 1, "should have exactly one obligation for the resource") + s.Equal(mockObligationFQN4, perResource[0][0]) + s.Require().Len(all, 1, "should have exactly one obligation total") + s.Contains(all, mockObligationFQN4) +} + +func (s *ObligationsPDPSuite) Test_GetRequiredObligations_CustomAction_MixedResources_Triggered() { + // Test custom action with mixed resource types + resources := []*authz.Resource{ + // Direct attribute that triggers custom obligation + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN3}, + }, + }, + }, + // Registered resource that also triggers custom obligation + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN3, + }, + }, + // Registered resource that doesn't trigger custom obligation (only has read action) + { + Resource: &authz.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: mockRegResValFQN2, + }, + }, + } + decisionRequestContext := emptyDecisionRequestContext + + perResource, all, err := s.pdp.GetRequiredObligations(s.T().Context(), actionCustom, resources, decisionRequestContext) + + s.Require().NoError(err) + s.Require().Len(perResource, 3, "should have results for exactly three resources") + + // First resource (direct attribute) should trigger custom obligation + s.Require().Len(perResource[0], 1, "first resource should have exactly one obligation") + s.Equal(mockObligationFQN4, perResource[0][0]) + + // Second resource (registered resource with custom action) should trigger custom obligation + s.Require().Len(perResource[1], 1, "second resource should have exactly one obligation") + s.Equal(mockObligationFQN4, perResource[1][0]) + + // Third resource (registered resource without custom action) should trigger no obligations + s.Empty(perResource[2], "third resource should have no obligations for custom action") + + // Should have exactly one unique obligation in total (deduplicated) + s.Require().Len(all, 1, "should have exactly one unique obligation total") + 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) createObligation(oblFQN, attrValFQN, clientID string, action *policy.Action) *policy.Obligation { + trigger := &policy.ObligationTrigger{ + Action: action, + AttributeValue: &policy.Value{Fqn: attrValFQN}, + } + + if clientID != "" { + trigger.Context = []*policy.RequestContext{ + { + Pep: &policy.PolicyEnforcementPoint{ + ClientId: clientID, + }, + }, + } + } + + return &policy.Obligation{ + Values: []*policy.ObligationValue{ + { + Fqn: oblFQN, + Triggers: []*policy.ObligationTrigger{trigger}, + }, + }, + } +}