diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 6afaf5cd08..4db41de696 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -213,7 +213,7 @@ jobs: mkcert -cert-file ./keys/platform.crt -key-file ./keys/platform-key.pem localhost cp opentdf-dev.yaml opentdf.yaml yq eval '.server.tls.enabled = true' -i opentdf.yaml - yq eval '.trace = {"enabled":true}' -i opentdf.yaml + yq eval '.server.trace.enabled = true' -i opentdf.yaml - name: Added Trusted Certs run: | sudo chmod -R 777 ./keys diff --git a/service/authorization/v2/authorization.go b/service/authorization/v2/authorization.go index 815f2886f2..c073823aaa 100644 --- a/service/authorization/v2/authorization.go +++ b/service/authorization/v2/authorization.go @@ -188,7 +188,7 @@ func (as *Service) GetDecision(ctx context.Context, req *connect.Request[authzV2 return nil, statusifyError(ctx, as.logger, err) } - decisions, permitted, err := pdp.GetDecision( + decision, err := pdp.GetDecision( ctx, entityIdentifier, action, @@ -199,10 +199,15 @@ func (as *Service) GetDecision(ctx context.Context, req *connect.Request[authzV2 if err != nil { return nil, statusifyError(ctx, as.logger, err) } - resp, err := rollupSingleResourceDecision(permitted, decisions) + + resourceDecisions, err := rollupResourceDecisions(decision) if err != nil { return nil, statusifyError(ctx, as.logger, err) } + + resp := &authzV2.GetDecisionResponse{ + Decision: resourceDecisions[0], + } return connect.NewResponse(resp), nil } @@ -232,7 +237,7 @@ func (as *Service) GetDecisionMultiResource(ctx context.Context, req *connect.Re return nil, statusifyError(ctx, as.logger, err) } - decisions, allPermitted, err := pdp.GetDecision( + decision, err := pdp.GetDecision( ctx, entityIdentifier, action, @@ -244,14 +249,14 @@ func (as *Service) GetDecisionMultiResource(ctx context.Context, req *connect.Re return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetDecision, err)) } - resourceDecisions, err := rollupMultiResourceDecisions(decisions) + resourceDecisions, err := rollupResourceDecisions(decision) if err != nil { return nil, statusifyError(ctx, as.logger, err) } resp := &authzV2.GetDecisionMultiResourceResponse{ AllPermitted: &wrapperspb.BoolValue{ - Value: allPermitted, + Value: decision.AllPermitted, }, ResourceDecisions: resourceDecisions, } @@ -291,19 +296,19 @@ func (as *Service) GetDecisionBulk(ctx context.Context, req *connect.Request[aut resources := request.GetResources() fulfillableObligations := request.GetFulfillableObligationFqns() - decisions, allPermitted, err := pdp.GetDecision(ctx, entityIdentifier, action, resources, reqContext, fulfillableObligations) + decision, err := pdp.GetDecision(ctx, entityIdentifier, action, resources, reqContext, fulfillableObligations) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetDecision, err)) } - resourceDecisions, err := rollupMultiResourceDecisions(decisions) + resourceDecisions, err := rollupResourceDecisions(decision) if err != nil { return nil, statusifyError(ctx, as.logger, err, slog.Int("index", idx)) } decisionResponse := &authzV2.GetDecisionMultiResourceResponse{ AllPermitted: &wrapperspb.BoolValue{ - Value: allPermitted, + Value: decision.AllPermitted, }, ResourceDecisions: resourceDecisions, } diff --git a/service/authorization/v2/authorization_test.go b/service/authorization/v2/authorization_test.go index bbb4ec38ed..0d0e80259f 100644 --- a/service/authorization/v2/authorization_test.go +++ b/service/authorization/v2/authorization_test.go @@ -1361,169 +1361,24 @@ func Test_GetEntitlementsRequest_Fails(t *testing.T) { } } -func Test_RollupSingleResourceDecision(t *testing.T) { +func Test_RollupResourceDecisions(t *testing.T) { tests := []struct { name string - permitted bool - decisions []*access.Decision - expectedResult *authzV2.GetDecisionResponse - expectedError error - }{ - { - name: "should return permit decision when permitted is true", - permitted: true, - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{ - { - ResourceID: "resource-123", - }, - }, - }, - }, - expectedResult: &authzV2.GetDecisionResponse{ - Decision: &authzV2.ResourceDecision{ - Decision: authzV2.Decision_DECISION_PERMIT, - EphemeralResourceId: "resource-123", - }, - }, - expectedError: nil, - }, - { - name: "should surface obligations in a permit decision", - permitted: true, - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{ - { - ResourceID: "resource-123", - RequiredObligationValueFQNs: []string{ - "obligation-abc", - }, - }, - }, - }, - }, - expectedResult: &authzV2.GetDecisionResponse{ - Decision: &authzV2.ResourceDecision{ - Decision: authzV2.Decision_DECISION_PERMIT, - EphemeralResourceId: "resource-123", - RequiredObligations: []string{ - "obligation-abc", - }, - }, - }, - expectedError: nil, - }, - { - name: "should return deny decision when permitted is false", - permitted: false, - decisions: []*access.Decision{ - { - AllPermitted: true, // Verify permitted takes precedence - Results: []access.ResourceDecision{ - { - ResourceID: "resource-123", - }, - }, - }, - }, - expectedResult: &authzV2.GetDecisionResponse{ - Decision: &authzV2.ResourceDecision{ - Decision: authzV2.Decision_DECISION_DENY, - EphemeralResourceId: "resource-123", - }, - }, - expectedError: nil, - }, - { - name: "should surface obligations within a deny decision", - permitted: false, - decisions: []*access.Decision{ - { - AllPermitted: true, // Verify permitted takes precedence - Results: []access.ResourceDecision{ - { - ResourceID: "resource-123", - RequiredObligationValueFQNs: []string{"obligation-123"}, - }, - }, - }, - }, - expectedResult: &authzV2.GetDecisionResponse{ - Decision: &authzV2.ResourceDecision{ - Decision: authzV2.Decision_DECISION_DENY, - EphemeralResourceId: "resource-123", - RequiredObligations: []string{"obligation-123"}, - }, - }, - expectedError: nil, - }, - { - name: "should return error when no decisions are provided", - permitted: true, - decisions: []*access.Decision{}, - expectedResult: nil, - expectedError: ErrNoDecisions, - }, - { - name: "should return error when decision has no results", - permitted: true, - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{}, - }, - }, - expectedResult: nil, - expectedError: ErrDecisionMustHaveResults, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := rollupSingleResourceDecision(tc.permitted, tc.decisions) - - if tc.expectedError != nil { - require.Error(t, err) - require.ErrorIs(t, err, tc.expectedError) - assert.Nil(t, result) - } else { - require.NoError(t, err) - assert.True(t, proto.Equal(tc.expectedResult, result)) - } - }) - } -} - -func Test_RollupMultiResourceDecisions(t *testing.T) { - tests := []struct { - name string - decisions []*access.Decision + decision *access.Decision expectedResult []*authzV2.ResourceDecision expectedError error }{ { name: "should return multiple permit decisions", - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{ - { - Passed: true, - ResourceID: "resource-123", - }, + decision: &access.Decision{ + Results: []access.ResourceDecision{ + { + Passed: true, + ResourceID: "resource-123", }, - }, - { - AllPermitted: true, - Results: []access.ResourceDecision{ - { - Passed: true, - ResourceID: "resource-456", - }, + { + Passed: true, + ResourceID: "resource-456", }, }, }, @@ -1540,23 +1395,15 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, { name: "should return mix of permit and deny decisions", - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{ - { - Passed: true, - ResourceID: "resource-123", - }, + decision: &access.Decision{ + Results: []access.ResourceDecision{ + { + Passed: true, + ResourceID: "resource-123", }, - }, - { - AllPermitted: false, - Results: []access.ResourceDecision{ - { - Passed: false, - ResourceID: "resource-456", - }, + { + Passed: false, + ResourceID: "resource-456", }, }, }, @@ -1573,27 +1420,19 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, { name: "should rely on results and default to false decisions", - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{ - { - Passed: true, - ResourceID: "resource-123", - }, - { - Passed: false, - ResourceID: "resource-abc", - }, + decision: &access.Decision{ + Results: []access.ResourceDecision{ + { + Passed: true, + ResourceID: "resource-123", }, - }, - { - AllPermitted: false, - Results: []access.ResourceDecision{ - { - Passed: false, - ResourceID: "resource-456", - }, + { + Passed: false, + ResourceID: "resource-abc", + }, + { + Passed: false, + ResourceID: "resource-456", }, }, }, @@ -1614,27 +1453,19 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, { name: "should ignore global access and care about resource decisions predominantly", - decisions: []*access.Decision{ - { - AllPermitted: false, - Results: []access.ResourceDecision{ - { - Passed: false, - ResourceID: "resource-123", - }, - { - Passed: true, - ResourceID: "resource-abc", - }, + decision: &access.Decision{ + Results: []access.ResourceDecision{ + { + Passed: false, + ResourceID: "resource-123", }, - }, - { - AllPermitted: false, - Results: []access.ResourceDecision{ - { - Passed: true, - ResourceID: "resource-456", - }, + { + Passed: true, + ResourceID: "resource-abc", + }, + { + Passed: true, + ResourceID: "resource-456", }, }, }, @@ -1655,41 +1486,33 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, { name: "should return obligations whenever found on a resource", - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{ - { - Passed: true, - ResourceID: "resource-123", - RequiredObligationValueFQNs: []string{ - "obligation-123", - "obligation-abc", - "obligation-456", - }, + decision: &access.Decision{ + Results: []access.ResourceDecision{ + { + Passed: true, + ResourceID: "resource-123", + RequiredObligationValueFQNs: []string{ + "obligation-123", + "obligation-abc", + "obligation-456", }, - { - Passed: true, - ResourceID: "resource-abc", - RequiredObligationValueFQNs: []string{ - "obligation-abc", - }, + }, + { + Passed: true, + ResourceID: "resource-abc", + RequiredObligationValueFQNs: []string{ + "obligation-abc", }, }, - }, - { - AllPermitted: false, - Results: []access.ResourceDecision{ - { - Passed: false, - ResourceID: "resource-456", - }, - { - Passed: true, - ResourceID: "resource-extra", - RequiredObligationValueFQNs: []string{ - "obligation-extra", - }, + { + Passed: false, + ResourceID: "resource-456", + }, + { + Passed: true, + ResourceID: "resource-extra", + RequiredObligationValueFQNs: []string{ + "obligation-extra", }, }, }, @@ -1726,11 +1549,8 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, { name: "should return error when decision has no results", - decisions: []*access.Decision{ - { - AllPermitted: true, - Results: []access.ResourceDecision{}, - }, + decision: &access.Decision{ + Results: []access.ResourceDecision{}, }, expectedError: ErrDecisionMustHaveResults, }, @@ -1738,7 +1558,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := rollupMultiResourceDecisions(tc.decisions) + result, err := rollupResourceDecisions(tc.decision) if tc.expectedError != nil { require.Error(t, err) @@ -1755,78 +1575,30 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { } } -func Test_RollupMultiResourceDecisions_Simple(t *testing.T) { - // This test checks the minimal viable structure to pass through rollupMultiResourceDecision - decision := &access.Decision{ - Results: []access.ResourceDecision{ - { - Passed: true, - ResourceID: "resource-123", - }, - }, - } - - decisions := []*access.Decision{decision} - - result, err := rollupMultiResourceDecisions(decisions) - - require.NoError(t, err) - assert.Len(t, result, 1) - assert.Equal(t, "resource-123", result[0].GetEphemeralResourceId()) - assert.Equal(t, authzV2.Decision_DECISION_PERMIT, result[0].GetDecision()) -} - -func Test_RollupMultiResourceDecisions_WithNilChecks(t *testing.T) { - t.Run("nil decisions array", func(t *testing.T) { - var decisions []*access.Decision - _, err := rollupMultiResourceDecisions(decisions) - require.Error(t, err) - require.ErrorIs(t, err, ErrNoDecisions) - }) - - t.Run("nil decision in array", func(t *testing.T) { - decisions := []*access.Decision{nil} - _, err := rollupMultiResourceDecisions(decisions) +func Test_RollupResourceDecisions_WithNilChecks(t *testing.T) { + t.Run("nil decision", func(t *testing.T) { + var decision *access.Decision + _, err := rollupResourceDecisions(decision) require.Error(t, err) require.ErrorIs(t, err, ErrDecisionCannotBeNil) }) t.Run("nil Results field", func(t *testing.T) { - decisions := []*access.Decision{ - { - AllPermitted: true, - Results: nil, - }, + decision := &access.Decision{ + AllPermitted: true, + Results: nil, } - _, err := rollupMultiResourceDecisions(decisions) + _, err := rollupResourceDecisions(decision) require.Error(t, err) require.ErrorIs(t, err, ErrDecisionMustHaveResults) }) -} - -func Test_RollupSingleResourceDecision_WithNilChecks(t *testing.T) { - t.Run("nil decisions array", func(t *testing.T) { - var decisions []*access.Decision - _, err := rollupSingleResourceDecision(true, decisions) - require.Error(t, err) - require.ErrorIs(t, err, ErrNoDecisions) - }) - t.Run("nil decision in array", func(t *testing.T) { - decisions := []*access.Decision{nil} - _, err := rollupSingleResourceDecision(true, decisions) - require.Error(t, err) - require.ErrorIs(t, err, ErrDecisionCannotBeNil) - }) - - t.Run("nil Results field", func(t *testing.T) { - decisions := []*access.Decision{ - { - AllPermitted: true, - Results: nil, - }, + t.Run("empty Results field", func(t *testing.T) { + decision := &access.Decision{ + AllPermitted: true, + Results: []access.ResourceDecision{}, } - _, err := rollupSingleResourceDecision(true, decisions) + _, err := rollupResourceDecisions(decision) require.Error(t, err) require.ErrorIs(t, err, ErrDecisionMustHaveResults) }) diff --git a/service/authorization/v2/helpers.go b/service/authorization/v2/helpers.go index d8df45ffc3..f485c316c5 100644 --- a/service/authorization/v2/helpers.go +++ b/service/authorization/v2/helpers.go @@ -15,78 +15,37 @@ import ( var ( ErrFailedToRollupDecision = errors.New("failed to rollup decision") ErrResponseSafeInternalError = errors.New("an unexpected error occurred") - ErrNoDecisions = errors.New("no decisions returned") ErrDecisionCannotBeNil = errors.New("decision cannot be nil") ErrDecisionMustHaveResults = errors.New("decision must have results") ) -// rollupMultiResourceDecisions creates a standardized response for multi-resource decisions -// by processing the decisions returned from the PDP. -func rollupMultiResourceDecisions( - decisions []*access.Decision, -) ([]*authzV2.ResourceDecision, error) { - if len(decisions) == 0 { - return nil, errors.Join(ErrFailedToRollupDecision, ErrNoDecisions) - } - - var resourceDecisions []*authzV2.ResourceDecision - - for idx, decision := range decisions { - if decision == nil { - return nil, errors.Join(ErrFailedToRollupDecision, fmt.Errorf("%w: index %d", ErrDecisionCannotBeNil, idx)) - } - if len(decision.Results) == 0 { - return nil, errors.Join(ErrFailedToRollupDecision, fmt.Errorf("%w: %+v", ErrDecisionMustHaveResults, decision)) - } - for _, result := range decision.Results { - access := authzV2.Decision_DECISION_DENY - if result.Passed { - access = authzV2.Decision_DECISION_PERMIT - } - resourceDecision := &authzV2.ResourceDecision{ - Decision: access, - EphemeralResourceId: result.ResourceID, - RequiredObligations: result.RequiredObligationValueFQNs, - } - resourceDecisions = append(resourceDecisions, resourceDecision) - } - } - - return resourceDecisions, nil -} - -// rollupSingleResourceDecision creates a standardized response for a single resource decision +// rollupResourceDecisions creates a standardized response for multi-resource decisions // by processing the decision returned from the PDP. -func rollupSingleResourceDecision( - permitted bool, - decisions []*access.Decision, -) (*authzV2.GetDecisionResponse, error) { - if len(decisions) == 0 { - return nil, errors.Join(ErrFailedToRollupDecision, ErrNoDecisions) - } - - decision := decisions[0] +func rollupResourceDecisions( + decision *access.Decision, +) ([]*authzV2.ResourceDecision, error) { if decision == nil { return nil, errors.Join(ErrFailedToRollupDecision, ErrDecisionCannotBeNil) } - if len(decision.Results) == 0 { return nil, errors.Join(ErrFailedToRollupDecision, fmt.Errorf("%w: %+v", ErrDecisionMustHaveResults, decision)) } - result := decision.Results[0] - access := authzV2.Decision_DECISION_DENY - if permitted { - access = authzV2.Decision_DECISION_PERMIT - } - resourceDecision := &authzV2.ResourceDecision{ - Decision: access, - EphemeralResourceId: result.ResourceID, - RequiredObligations: result.RequiredObligationValueFQNs, + resourceDecisions := make([]*authzV2.ResourceDecision, len(decision.Results)) + for idx, result := range decision.Results { + access := authzV2.Decision_DECISION_DENY + if result.Passed { + access = authzV2.Decision_DECISION_PERMIT + } + resourceDecision := &authzV2.ResourceDecision{ + Decision: access, + EphemeralResourceId: result.ResourceID, + RequiredObligations: result.RequiredObligationValueFQNs, + } + resourceDecisions[idx] = resourceDecision } - return &authzV2.GetDecisionResponse{ - Decision: resourceDecision, - }, nil + + return resourceDecisions, nil } // Checks for known error types and returns standardized error codes and messages diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index 5ffa8257ca..e437815c26 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -12,6 +12,7 @@ import ( 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/internal/access/v2/obligations" "github.com/opentdf/platform/service/logger" ) @@ -22,7 +23,7 @@ var ( ErrInvalidRegisteredResourceValue = errors.New("access: invalid registered resource value") ) -// getDefinition parses the value FQN and uses it to retrieve the definition from the provided definitions canmap +// getDefinition parses the value FQN and uses it to retrieve the definition from the provided definitions map func getDefinition(valueFQN string, allDefinitionsByDefFQN map[string]*policy.Attribute) (*policy.Attribute, error) { parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](valueFQN) if err != nil { @@ -263,3 +264,100 @@ func getResourceDecisionableAttributes( return decisionableAttributes, nil } + +// applyObligationsAndConsolidate: +// +// 1. IFF entitled to a resource on the passed in Decision, adds corresponding obligations and the obligations +// satisfied state to the ResourceDecision +// 2. Builds a list of ResourceDecisions for audit (which always should see obligation decision state) +// 3. Merges the Decision's ResourceDecisions with those consolidated across the other entity representations +func applyObligationsAndConsolidate( + consolidatedAcrossEntityRepresentations []ResourceDecision, + currentEntityRepresentationDecision *Decision, + obligationDecision obligations.ObligationPolicyDecision, +) ([]ResourceDecision, []ResourceDecision, error) { + hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 + isFirstEntity := consolidatedAcrossEntityRepresentations == nil + numResourceDecisions := len(currentEntityRepresentationDecision.Results) + numObligationResourceDecisions := len(obligationDecision.PerResourceDecisions) + + // First entity: validate both lists of decisions are equal length + if isFirstEntity && hasRequiredObligations { + if numResourceDecisions != numObligationResourceDecisions { + return nil, nil, fmt.Errorf( + "obligation decision count mismatch: expected %d but got %d", + numResourceDecisions, numObligationResourceDecisions, + ) + } + } + + // Subsequent entities: validate length matches consolidated + if !isFirstEntity && len(consolidatedAcrossEntityRepresentations) != numResourceDecisions { + return nil, nil, fmt.Errorf( + "%w: consolidatedAcrossEntityRepresentations has %d but currentEntityRepresentationDecision has %d", + ErrResourceDecisionLengthMismatch, len(consolidatedAcrossEntityRepresentations), numResourceDecisions, + ) + } + + consolidated := make([]ResourceDecision, numResourceDecisions) + resourceDecisionAuditRecords := make([]ResourceDecision, numResourceDecisions) + + for resourceDecisionIdx := range currentEntityRepresentationDecision.Results { + currentResourceDecision := ¤tEntityRepresentationDecision.Results[resourceDecisionIdx] + + // Step 1: Apply obligations to each resource decision on the current decision + currentResourceDecision.ObligationsSatisfied = true + var obligationFQNs []string + + if hasRequiredObligations { + obligationDecisionOnResource := obligationDecision.PerResourceDecisions[resourceDecisionIdx] + currentResourceDecision.ObligationsSatisfied = obligationDecisionOnResource.ObligationsSatisfied + obligationFQNs = obligationDecisionOnResource.RequiredObligationValueFQNs + + // Only set obligations in response if entitled + if currentResourceDecision.Entitled { + currentResourceDecision.RequiredObligationValueFQNs = obligationFQNs + } + } + + currentResourceDecision.Passed = currentResourceDecision.Entitled && currentResourceDecision.ObligationsSatisfied + + // Step 2: Consolidate with accumulated (if not first entity) + var finalDecision ResourceDecision + if isFirstEntity { + finalDecision = *currentResourceDecision + } else { + resourceDecisionAllEntitiesSoFar := consolidatedAcrossEntityRepresentations[resourceDecisionIdx] + + // Validate resource IDs match + if resourceDecisionAllEntitiesSoFar.ResourceID != currentResourceDecision.ResourceID { + return nil, nil, fmt.Errorf( + "%w at index %d: %q vs %q", + ErrResourceDecisionIDMismatch, resourceDecisionIdx, resourceDecisionAllEntitiesSoFar.ResourceID, currentResourceDecision.ResourceID, + ) + } + + // AND together: all entity representations must be entitled + finalDecision = ResourceDecision{ + ResourceID: resourceDecisionAllEntitiesSoFar.ResourceID, + ResourceName: resourceDecisionAllEntitiesSoFar.ResourceName, + Entitled: resourceDecisionAllEntitiesSoFar.Entitled && currentResourceDecision.Entitled, + Passed: resourceDecisionAllEntitiesSoFar.Passed && currentResourceDecision.Passed, + ObligationsSatisfied: resourceDecisionAllEntitiesSoFar.ObligationsSatisfied && currentResourceDecision.ObligationsSatisfied, + } + + // Keep obligations if entitled, clear if not + if finalDecision.Entitled { + finalDecision.RequiredObligationValueFQNs = resourceDecisionAllEntitiesSoFar.RequiredObligationValueFQNs + } + } + + consolidated[resourceDecisionIdx] = finalDecision + + // Step 3: Create audit snapshot (always includes obligation context) + resourceDecisionAuditRecords[resourceDecisionIdx] = *currentResourceDecision + resourceDecisionAuditRecords[resourceDecisionIdx].RequiredObligationValueFQNs = obligationFQNs + } + + return consolidated, resourceDecisionAuditRecords, nil +} diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index 2e5d7f54e2..604359e6a8 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -6,6 +6,7 @@ import ( 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/internal/access/v2/obligations" "github.com/opentdf/platform/service/logger" "github.com/opentdf/platform/service/policy/actions" "github.com/stretchr/testify/assert" @@ -696,3 +697,416 @@ func TestMergeDeduplicatedActions(t *testing.T) { }) } } + +const ( + testObligation1FQN = "https://example.org/obligation/attr1/value/obl1" + testObligation2FQN = "https://example.org/obligation/attr2/value/obl2" + + testResource1ID = "resource1" + testResource2ID = "resource2" + testResource3ID = "resource3" + testResource1Name = "Resource One" + testResource2Name = "Resource Two" + testResource3Name = "Resource Three" +) + +func mkExpectedResourceDecision(id, name string, entitled, obligationsSatisfied, passed bool, obligations []string, dataRules ...DataRuleResult) ResourceDecision { + return ResourceDecision{ + Entitled: entitled, + ObligationsSatisfied: obligationsSatisfied, + Passed: passed, + ResourceID: id, + ResourceName: name, + RequiredObligationValueFQNs: obligations, + DataRuleResults: dataRules, + } +} + +func mkPerResourceDecision(satisfied bool, obligationFQNs ...string) obligations.PerResourceDecision { + return obligations.PerResourceDecision{ + ObligationsSatisfied: satisfied, + RequiredObligationValueFQNs: obligationFQNs, + } +} + +func assertResourceDecision(t *testing.T, expected, actual ResourceDecision, idx int, prefix string) { + t.Helper() + assert.Equal(t, expected.Entitled, actual.Entitled, "%s resource %d: Entitled mismatch", prefix, idx) + assert.Equal(t, expected.ObligationsSatisfied, actual.ObligationsSatisfied, "%s resource %d: ObligationsSatisfied mismatch", prefix, idx) + assert.Equal(t, expected.Passed, actual.Passed, "%s resource %d: Passed mismatch", prefix, idx) + assert.Equal(t, expected.ResourceID, actual.ResourceID, "%s resource %d: ResourceID mismatch", prefix, idx) + assert.Equal(t, expected.ResourceName, actual.ResourceName, "%s resource %d: ResourceName mismatch", prefix, idx) + assert.Equal(t, expected.RequiredObligationValueFQNs, actual.RequiredObligationValueFQNs, "%s resource %d: RequiredObligationValueFQNs mismatch", prefix, idx) + assert.Equal(t, expected.DataRuleResults, actual.DataRuleResults, "%s resource %d: DataRuleResults mismatch", prefix, idx) +} + +func Test_applyObligationsAndConsolidate(t *testing.T) { + testAttrFQN := "https://example.org/attr/test/value/v1" + + tests := []struct { + name string + accumulated []ResourceDecision + nextDecision *Decision + obligationDecision obligations.ObligationPolicyDecision + expectedConsolidated []ResourceDecision + expectedAudit []ResourceDecision + expectedErr error + }{ + // First entity scenarios + { + name: "first entity - no obligations", + accumulated: nil, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + }, + }, + { + name: "first entity - with obligations satisfied", + accumulated: nil, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + AllObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{testObligation1FQN}, + PerResourceDecisions: []obligations.PerResourceDecision{mkPerResourceDecision(true, testObligation1FQN)}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), + }, + }, + { + name: "first entity - obligations not satisfied", + accumulated: nil, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + AllObligationsSatisfied: false, + RequiredObligationValueFQNs: []string{testObligation1FQN}, + PerResourceDecisions: []obligations.PerResourceDecision{mkPerResourceDecision(false, testObligation1FQN)}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, false, false, []string{testObligation1FQN}), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, false, false, []string{testObligation1FQN}), + }, + }, + { + name: "first entity - not entitled", + accumulated: nil, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: false}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + AllObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{testObligation1FQN}, + PerResourceDecisions: []obligations.PerResourceDecision{mkPerResourceDecision(true, testObligation1FQN)}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, nil), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, []string{testObligation1FQN}), + }, + }, + // Second entity AND scenarios + { + name: "second entity - both entitled (AND succeeds)", + accumulated: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true, Passed: true, ObligationsSatisfied: true}, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + }, + }, + { + name: "second entity - first entitled, second not (AND fails)", + accumulated: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true, Passed: true, ObligationsSatisfied: true}, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: false}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, nil), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, nil), + }, + }, + { + name: "second entity - obligations preserved when both entitled", + accumulated: []ResourceDecision{ + { + ResourceID: testResource1ID, + ResourceName: testResource1Name, + Entitled: true, + Passed: true, + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{testObligation1FQN}, + }, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + }, + }, + { + name: "second entity - obligations cleared when second not entitled", + accumulated: []ResourceDecision{ + { + ResourceID: testResource1ID, + ResourceName: testResource1Name, + Entitled: true, + Passed: true, + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{testObligation1FQN}, + }, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: false}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, nil), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, nil), + }, + }, + // Multiple resources + { + name: "multiple resources - mixed entitlement", + accumulated: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true, Passed: true, ObligationsSatisfied: true}, + {ResourceID: testResource2ID, ResourceName: testResource2Name, Entitled: false, Passed: false, ObligationsSatisfied: true}, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + {ResourceID: testResource2ID, ResourceName: testResource2Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{{}, {}}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + mkExpectedResourceDecision(testResource2ID, testResource2Name, false, true, false, nil), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + mkExpectedResourceDecision(testResource2ID, testResource2Name, true, true, true, nil), + }, + }, + { + name: "multiple resources - mixed obligations", + accumulated: nil, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + {ResourceID: testResource2ID, ResourceName: testResource2Name, Entitled: false}, + {ResourceID: testResource3ID, ResourceName: testResource3Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + AllObligationsSatisfied: false, + RequiredObligationValueFQNs: []string{testObligation1FQN, testObligation2FQN}, + PerResourceDecisions: []obligations.PerResourceDecision{ + mkPerResourceDecision(true, testObligation1FQN), + mkPerResourceDecision(false, testObligation2FQN), + mkPerResourceDecision(false, testObligation2FQN), + }, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), + mkExpectedResourceDecision(testResource2ID, testResource2Name, false, false, false, nil), + mkExpectedResourceDecision(testResource3ID, testResource3Name, true, false, false, []string{testObligation2FQN}), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), + mkExpectedResourceDecision(testResource2ID, testResource2Name, false, false, false, []string{testObligation2FQN}), + mkExpectedResourceDecision(testResource3ID, testResource3Name, true, false, false, []string{testObligation2FQN}), + }, + }, + { + name: "first entity - data rule results in audit only", + accumulated: nil, + nextDecision: &Decision{ + Results: []ResourceDecision{ + { + ResourceID: testResource1ID, + ResourceName: testResource1Name, + Entitled: true, + DataRuleResults: []DataRuleResult{ + { + Passed: true, + ResourceValueFQNs: []string{testAttrFQN}, + }, + }, + }, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil, DataRuleResult{ + Passed: true, + ResourceValueFQNs: []string{testAttrFQN}, + }), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil, DataRuleResult{ + Passed: true, + ResourceValueFQNs: []string{testAttrFQN}, + }), + }, + }, + { + name: "second entity - data rule results in audit only", + accumulated: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true, Passed: true, ObligationsSatisfied: true}, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + { + ResourceID: testResource1ID, + ResourceName: testResource1Name, + Entitled: true, + DataRuleResults: []DataRuleResult{ + { + Passed: true, + ResourceValueFQNs: []string{testAttrFQN}, + }, + }, + }, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{ + RequiredObligationValueFQNs: []string{}, + PerResourceDecisions: []obligations.PerResourceDecision{}, + }, + expectedConsolidated: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), + }, + expectedAudit: []ResourceDecision{ + mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil, DataRuleResult{ + Passed: true, + ResourceValueFQNs: []string{testAttrFQN}, + }), + }, + }, + // Error scenarios + { + name: "length mismatch error", + accumulated: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true}, + {ResourceID: testResource2ID, ResourceName: testResource2Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{}, + expectedErr: ErrResourceDecisionLengthMismatch, + }, + { + name: "resource ID mismatch error", + accumulated: []ResourceDecision{ + {ResourceID: testResource1ID, ResourceName: testResource1Name, Entitled: true, Passed: true, ObligationsSatisfied: true}, + }, + nextDecision: &Decision{ + Results: []ResourceDecision{ + {ResourceID: testResource2ID, ResourceName: testResource2Name, Entitled: true}, + }, + }, + obligationDecision: obligations.ObligationPolicyDecision{}, + expectedErr: ErrResourceDecisionIDMismatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + consolidated, audit, err := applyObligationsAndConsolidate(tt.accumulated, tt.nextDecision, tt.obligationDecision) + + if tt.expectedErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.expectedErr) + return + } + + require.NoError(t, err) + require.Len(t, consolidated, len(tt.expectedConsolidated)) + require.Len(t, audit, len(tt.expectedAudit)) + + for i := range consolidated { + assertResourceDecision(t, tt.expectedConsolidated[i], consolidated[i], i, "consolidated") + } + + for i := range audit { + assertResourceDecision(t, tt.expectedAudit[i], audit[i], i, "audit") + } + }) + } +} diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 1aa2569a15..3f52427320 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -27,6 +27,8 @@ var ( ErrInvalidEntityType = errors.New("access: invalid entity type") ErrFailedToWithRequestTokenEntityIdentifier = errors.New("access: failed to use request token as entity identifier - none found in context") ErrInvalidWithRequestTokenEntityIdentifier = errors.New("access: invalid use request token as entity identifier - must be true if provided") + ErrResourceDecisionLengthMismatch = errors.New("access: resource decision length mismatch") + ErrResourceDecisionIDMismatch = errors.New("access: resource decision ID mismatch") requestAuthTokenEphemeralID = "with-request-token-auth-entity" ) @@ -40,7 +42,7 @@ type JustInTimePDP struct { obligationsPDP *obligations.ObligationsPolicyDecisionPoint } -// JustInTimePDP creates a new Policy Decision Point instance with no in-memory policy and a remote connection +// NewJustInTimePDP creates a new Policy Decision Point instance with no in-memory policy and a remote connection // via authenticated SDK, then fetches all entitlement policy from provided store interface or policy services directly. func NewJustInTimePDP( ctx context.Context, @@ -116,10 +118,12 @@ func NewJustInTimePDP( // reports that it can fulfill to ensure all can be satisfied. // // Then, it resolves the Entity Identifier into either the Registered Resource or a Token/Entity Chain and roundtrips to ERS -// for their representations. In the case of multiple entity representations, multiple decisions are returned (one per entity). +// for their representations. In the case of multiple entity representations, entitlement means ALL representations are entitled. // -// The result is a list of Decision objects (one per entity), along with a global boolean indicating whether or not all -// decisions were to permit: full entitlement + all triggered obligations fulfillable. +// The result is a single consolidated Decision object with one resource decision per requested resource: where access means +// full entitlement + all triggered obligations fulfillable. +// +// Individual entity representation decisions are audited separately to maintain visibility into the decision process. // // | Entity entitled | Triggered obligations are fulfillable | Decision | Required Obligations Returned | // | --------------- | ------------------------------------- | -------- | ------------------------------ | @@ -134,7 +138,7 @@ func (p *JustInTimePDP) GetDecision( resources []*authzV2.Resource, requestContext *policy.RequestContext, fulfillableObligationValueFQNs []string, -) ([]*Decision, bool, error) { +) (*Decision, error) { var ( entityRepresentations []*entityresolutionV2.EntityRepresentation err error @@ -150,8 +154,10 @@ func (p *JustInTimePDP) GetDecision( fulfillableObligationValueFQNs, ) if err != nil { - return nil, false, fmt.Errorf("failed to check obligations: %w", err) + return nil, fmt.Errorf("failed to check obligations: %w", err) } + hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 + allObligationsSatisfied := (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) switch entityIdentifier.GetIdentifier().(type) { case *authzV2.EntityIdentifier_EntityChain: @@ -168,111 +174,94 @@ func (p *JustInTimePDP) GetDecision( // Registered resources do not have entity representations, so only one decision is made decision, entitlements, err := p.pdp.GetDecisionRegisteredResource(ctx, regResValueFQN, action, resources) if err != nil { - return nil, false, fmt.Errorf("failed to get decision for registered resource value FQN [%s]: %w", regResValueFQN, err) + return nil, fmt.Errorf("failed to get decision for registered resource value FQN [%s]: %w", regResValueFQN, err) } if decision == nil { - return nil, false, fmt.Errorf("decision is nil for registered resource value FQN [%s]", regResValueFQN) + return nil, fmt.Errorf("decision is nil for registered resource value FQN [%s]", regResValueFQN) } - // Update resource decisions with obligations and set final access decision - hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 - entitledWithAnyObligationsSatisfied := decision.AllPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) - decision.AllPermitted = entitledWithAnyObligationsSatisfied - decisionWithObligationsWhenEntitled, auditResourceDecisions := getResourceDecisionsWithObligations(decision, obligationDecision) + // Apply obligations (no consolidation needed for single entity) + resourceDecisions, auditResourceDecisions, err := applyObligationsAndConsolidate(nil, decision, obligationDecision) + if err != nil { + return nil, fmt.Errorf("failed to apply obligations for registered resource [%s]: %w", regResValueFQN, err) + } - p.auditDecision(ctx, regResValueFQN, action, entitledWithAnyObligationsSatisfied, entitlements, fulfillableObligationValueFQNs, obligationDecision, auditResourceDecisions) - return []*Decision{decisionWithObligationsWhenEntitled}, entitledWithAnyObligationsSatisfied, nil + entitledWithAnyObligationsSatisfied := decision.AllPermitted && allObligationsSatisfied + decision.AllPermitted = entitledWithAnyObligationsSatisfied + decision.Results = resourceDecisions + + p.auditDecision( + ctx, + regResValueFQN, + action, + entitledWithAnyObligationsSatisfied, + entitlements, + fulfillableObligationValueFQNs, + obligationDecision, + auditResourceDecisions, + ) + return decision, nil default: - return nil, false, ErrInvalidEntityType + return nil, ErrInvalidEntityType } if err != nil { - return nil, false, fmt.Errorf("failed to resolve entity identifier: %w", err) + return nil, fmt.Errorf("failed to resolve entity identifier: %w", err) } - // Make initial entitlement decisions - entityDecisions := make([]*Decision, len(entityRepresentations)) - entityEntitlements := make([]map[string][]*policy.Action, len(entityRepresentations)) + // Get a decision on each entity representation and consolidate into an overall decision + var resourceDecisionsAcrossAllEntityReps []ResourceDecision allPermitted := true - for idx, entityRep := range entityRepresentations { - d, entitlements, err := p.pdp.GetDecision(ctx, entityRep, action, resources) + + for _, entityRep := range entityRepresentations { + entityRepresentationDecision, entitlements, err := p.pdp.GetDecision(ctx, entityRep, action, resources) if err != nil { - return nil, false, fmt.Errorf("failed to get decision for entityRepresentation with original id [%s]: %w", entityRep.GetOriginalId(), err) + return nil, fmt.Errorf("failed to get decision for entityRepresentation with original id [%s]: %w", entityRep.GetOriginalId(), err) } - if d == nil { - return nil, false, fmt.Errorf("decision is nil: %w", err) + if entityRepresentationDecision == nil { + return nil, fmt.Errorf("decision is nil: %w", err) } + // If any entity lacks access to any resource, update overall decision denial - if !d.AllPermitted { + if !entityRepresentationDecision.AllPermitted { allPermitted = false } - entityDecisions[idx] = d - entityEntitlements[idx] = entitlements - } - // Update resource decisions with obligations and set final access decision - hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 - allEntitledWithAnyObligationsSatisfied := allPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) - allPermitted = allEntitledWithAnyObligationsSatisfied - - // Propagate obligations within policy on each resource decision object - for entityIdx, decision := range entityDecisions { - // TODO: figure out this multi-entity response? - // entitledWithAnyObligationsSatisfied := decision.AllPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) - // decision.AllPermitted = entitledWithAnyObligationsSatisfied + // Add obligations (if entitled) to the entity rep decision, prep audit records for each resource decision, + // and consolidate resource decisions across all entity reps var auditResourceDecisions []ResourceDecision - decision, auditResourceDecisions = getResourceDecisionsWithObligations(decision, obligationDecision) - decision.AllPermitted = allPermitted - entityRepID := entityRepresentations[entityIdx].GetOriginalId() - p.auditDecision(ctx, entityRepID, action, allPermitted, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision, auditResourceDecisions) - } - - return entityDecisions, allPermitted, nil -} - -// getResourceDecisionsWithObligations updates the Decision Results with obligation info when -// entitled, then sets each Resource Decision's passed state. Obligations are always populated on -// the Resource Decisions returned separately for audit logs, but kept distinct to avoid leaking -// obligations to those not entitled. -func getResourceDecisionsWithObligations( - decision *Decision, - obligationDecision obligations.ObligationPolicyDecision, -) (*Decision, []ResourceDecision) { - hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 - - // Create audit snapshot with full obligation context for all resources, even if not entitled - auditResourceDecisions := make([]ResourceDecision, len(decision.Results)) - - for idx := range decision.Results { - resourceDecision := &decision.Results[idx] - - // Default all obligations satisfied when none are required - resourceDecision.ObligationsSatisfied = true - var obligationFQNs []string - - if hasRequiredObligations { - perResource := obligationDecision.RequiredObligationValueFQNsPerResource[idx] - resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied - obligationFQNs = perResource.RequiredObligationValueFQNs - - // Only set obligations in response if entitled - if resourceDecision.Entitled { - resourceDecision.RequiredObligationValueFQNs = obligationFQNs - } + resourceDecisionsAcrossAllEntityReps, auditResourceDecisions, err = applyObligationsAndConsolidate( + resourceDecisionsAcrossAllEntityReps, + entityRepresentationDecision, + obligationDecision, + ) + if err != nil { + return nil, fmt.Errorf("failed to apply obligations and consolidate for entity representation [%s]: %w", entityRep.GetOriginalId(), err) } - resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied - - // For audit, copy but always include list of required obligations even if not entitled - auditResourceDecisions[idx] = *resourceDecision - auditResourceDecisions[idx].RequiredObligationValueFQNs = obligationFQNs - } - - return decision, auditResourceDecisions + // Audit decision for this entity representation + entityAllPermitted := entityRepresentationDecision.AllPermitted && allObligationsSatisfied + p.auditDecision( + ctx, + entityRep.GetOriginalId(), + action, + entityAllPermitted, + entitlements, + fulfillableObligationValueFQNs, + obligationDecision, + auditResourceDecisions, + ) + } + + allEntitledWithAllObligationsSatisfied := allPermitted && allObligationsSatisfied + return &Decision{ + AllPermitted: allEntitledWithAllObligationsSatisfied, + Results: resourceDecisionsAcrossAllEntityReps, + }, nil } -// GetEntitlements retrieves the entitlements for the provided entity chain. -// It resolves the entity chain to get the entity representations and then calls the embedded PDP to get the entitlements. +// GetEntitlements retrieves the entitlements for the provided entity identifier. +// It resolves the entity identifier to get the entity representations and then calls the embedded PDP to get the entitlements. func (p *JustInTimePDP) GetEntitlements( ctx context.Context, entityIdentifier *authzV2.EntityIdentifier, @@ -313,9 +302,8 @@ func (p *JustInTimePDP) GetEntitlements( if err != nil { return nil, fmt.Errorf("failed to get matched subject mappings: %w", err) } - // If no subject mappings are found, return empty entitlements - if matchedSubjectMappings == nil { - // TODO: is this an error case? + // If no subject mappings matched, return empty entitlements + if len(matchedSubjectMappings) == 0 { p.logger.DebugContext(ctx, "matched subject mappings is empty") return nil, nil } @@ -332,7 +320,7 @@ func (p *JustInTimePDP) getMatchedSubjectMappings( ctx context.Context, entityRepresentations []*entityresolutionV2.EntityRepresentation, ) ([]*policy.SubjectMapping, error) { - // Break the entity down the entities into their properties/selectors and retrieve only those subject mappings + // Break the entities down into their properties/selectors and retrieve only those subject mappings subjectProperties := make([]*policy.SubjectProperty, 0) subjectPropertySet := make(map[string]struct{}) for _, entityRep := range entityRepresentations { diff --git a/service/internal/access/v2/just_in_time_pdp_test.go b/service/internal/access/v2/just_in_time_pdp_test.go deleted file mode 100644 index 9f9f6a3689..0000000000 --- a/service/internal/access/v2/just_in_time_pdp_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package access - -import ( - "testing" - - "github.com/opentdf/platform/protocol/go/policy" - "github.com/opentdf/platform/service/internal/access/v2/obligations" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - testObligation1FQN = "https://example.org/obligation/attr1/value/obl1" - testObligation2FQN = "https://example.org/obligation/attr2/value/obl2" - testObligation3FQN = "https://example.org/obligation/attr3/value/obl3" - - testResource1ID = "resource1" - testResource2ID = "resource2" - testResource3ID = "resource3" - testResource1Name = "Resource One" - testResource2Name = "Resource Two" - testResource3Name = "Resource Three" - - testAttr1ValueFQN = "https://example.org/attr/attr1/value/val1" -) - -func mkResourceDecision(id, name string, entitled bool, dataRules ...DataRuleResult) ResourceDecision { - return ResourceDecision{ - Entitled: entitled, - ResourceID: id, - ResourceName: name, - DataRuleResults: dataRules, - } -} - -func mkExpectedResourceDecision(id, name string, entitled, obligationsSatisfied, passed bool, obligations []string, dataRules ...DataRuleResult) ResourceDecision { - return ResourceDecision{ - Entitled: entitled, - ObligationsSatisfied: obligationsSatisfied, - Passed: passed, - ResourceID: id, - ResourceName: name, - RequiredObligationValueFQNs: obligations, - DataRuleResults: dataRules, - } -} - -func mkPerResourceDecision(satisfied bool, obligationFQNs ...string) obligations.PerResourceDecision { - return obligations.PerResourceDecision{ - ObligationsSatisfied: satisfied, - RequiredObligationValueFQNs: obligationFQNs, - } -} - -func assertResourceDecision(t *testing.T, expected, actual ResourceDecision, idx int, prefix string) { - t.Helper() - assert.Equal(t, expected.Entitled, actual.Entitled, "%s resource %d: Entitled mismatch", prefix, idx) - assert.Equal(t, expected.ObligationsSatisfied, actual.ObligationsSatisfied, "%s resource %d: ObligationsSatisfied mismatch", prefix, idx) - assert.Equal(t, expected.Passed, actual.Passed, "%s resource %d: Passed mismatch", prefix, idx) - assert.Equal(t, expected.ResourceID, actual.ResourceID, "%s resource %d: ResourceID mismatch", prefix, idx) - assert.Equal(t, expected.ResourceName, actual.ResourceName, "%s resource %d: ResourceName mismatch", prefix, idx) - assert.Equal(t, expected.RequiredObligationValueFQNs, actual.RequiredObligationValueFQNs, "%s resource %d: RequiredObligationValueFQNs mismatch", prefix, idx) - assert.Equal(t, expected.DataRuleResults, actual.DataRuleResults, "%s resource %d: DataRuleResults mismatch", prefix, idx) -} - -func Test_getResourceDecisionsWithObligations(t *testing.T) { - tests := []struct { - name string - decision *Decision - obligationDecision obligations.ObligationPolicyDecision - expectedDecision *Decision - expectedAuditDecisions []ResourceDecision - }{ - { - name: "entitled: true, obligations: none", - decision: &Decision{ - Results: []ResourceDecision{ - mkResourceDecision(testResource1ID, testResource1Name, true), - mkResourceDecision(testResource2ID, testResource2Name, true), - }, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - RequiredObligationValueFQNs: []string{}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{{}, {}}, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), - mkExpectedResourceDecision(testResource2ID, testResource2Name, true, true, true, nil), - }, - }, - expectedAuditDecisions: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, nil), - mkExpectedResourceDecision(testResource2ID, testResource2Name, true, true, true, nil), - }, - }, - { - name: "entitled: true, obligations: required and satisfied", - decision: &Decision{ - Results: []ResourceDecision{mkResourceDecision(testResource1ID, testResource1Name, true)}, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - AllObligationsSatisfied: true, - RequiredObligationValueFQNs: []string{testObligation1FQN}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{mkPerResourceDecision(true, testObligation1FQN)}, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN})}, - }, - expectedAuditDecisions: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), - }, - }, - { - name: "entitled: false, obligations: required and satisfied", - decision: &Decision{ - Results: []ResourceDecision{mkResourceDecision(testResource1ID, testResource1Name, false)}, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - AllObligationsSatisfied: true, - RequiredObligationValueFQNs: []string{testObligation1FQN}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{mkPerResourceDecision(true, testObligation1FQN)}, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, nil)}, - }, - expectedAuditDecisions: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, false, true, false, []string{testObligation1FQN}), - }, - }, - { - name: "entitled: true, obligations: required and not satisfied", - decision: &Decision{ - Results: []ResourceDecision{mkResourceDecision(testResource1ID, testResource1Name, true)}, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - AllObligationsSatisfied: false, - RequiredObligationValueFQNs: []string{testObligation1FQN}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{mkPerResourceDecision(false, testObligation1FQN)}, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{mkExpectedResourceDecision(testResource1ID, testResource1Name, true, false, false, []string{testObligation1FQN})}, - }, - expectedAuditDecisions: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, false, false, []string{testObligation1FQN}), - }, - }, - { - name: "multiple resources: mixed entitlement and obligation states", - decision: &Decision{ - Results: []ResourceDecision{ - mkResourceDecision(testResource1ID, testResource1Name, true), - mkResourceDecision(testResource2ID, testResource2Name, false), - mkResourceDecision(testResource3ID, testResource3Name, true), - }, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - AllObligationsSatisfied: false, - RequiredObligationValueFQNs: []string{testObligation1FQN, testObligation2FQN}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{ - mkPerResourceDecision(true, testObligation1FQN), - mkPerResourceDecision(false, testObligation2FQN), - mkPerResourceDecision(false, testObligation2FQN), - }, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), - mkExpectedResourceDecision(testResource2ID, testResource2Name, false, false, false, nil), - mkExpectedResourceDecision(testResource3ID, testResource3Name, true, false, false, []string{testObligation2FQN}), - }, - }, - expectedAuditDecisions: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}), - mkExpectedResourceDecision(testResource2ID, testResource2Name, false, false, false, []string{testObligation2FQN}), - mkExpectedResourceDecision(testResource3ID, testResource3Name, true, false, false, []string{testObligation2FQN}), - }, - }, - { - name: "entitled: true, obligations: satisfied and multiple per resource", - decision: &Decision{ - Results: []ResourceDecision{mkResourceDecision(testResource1ID, testResource1Name, true)}, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - AllObligationsSatisfied: true, - RequiredObligationValueFQNs: []string{testObligation1FQN, testObligation2FQN, testObligation3FQN}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{ - mkPerResourceDecision(true, testObligation1FQN, testObligation2FQN, testObligation3FQN), - }, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN, testObligation2FQN, testObligation3FQN}), - }, - }, - expectedAuditDecisions: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN, testObligation2FQN, testObligation3FQN}), - }, - }, - { - name: "no resources", - decision: &Decision{ - Results: []ResourceDecision{}, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - RequiredObligationValueFQNs: []string{}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{}, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{}, - }, - expectedAuditDecisions: []ResourceDecision{}, - }, - { - name: "entitled: true, obligations: required, data rules preserved", - decision: &Decision{ - Results: []ResourceDecision{ - mkResourceDecision(testResource1ID, testResource1Name, true, DataRuleResult{ - Passed: true, - ResourceValueFQNs: []string{testAttr1ValueFQN}, - Attribute: &policy.Attribute{Name: "attr1"}, - }), - }, - }, - obligationDecision: obligations.ObligationPolicyDecision{ - AllObligationsSatisfied: true, - RequiredObligationValueFQNs: []string{testObligation1FQN}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{mkPerResourceDecision(true, testObligation1FQN)}, - }, - expectedDecision: &Decision{ - Results: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}, DataRuleResult{ - Passed: true, - ResourceValueFQNs: []string{testAttr1ValueFQN}, - Attribute: &policy.Attribute{Name: "attr1"}, - }), - }, - }, - expectedAuditDecisions: []ResourceDecision{ - mkExpectedResourceDecision(testResource1ID, testResource1Name, true, true, true, []string{testObligation1FQN}, DataRuleResult{ - Passed: true, - ResourceValueFQNs: []string{testAttr1ValueFQN}, - Attribute: &policy.Attribute{Name: "attr1"}, - }), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resultDecision, auditDecisions := getResourceDecisionsWithObligations(tt.decision, tt.obligationDecision) - - require.NotNil(t, resultDecision) - require.Len(t, resultDecision.Results, len(tt.expectedDecision.Results)) - - for i := range resultDecision.Results { - assertResourceDecision(t, tt.expectedDecision.Results[i], resultDecision.Results[i], i, "decision") - } - - require.Len(t, auditDecisions, len(tt.expectedAuditDecisions)) - for i := range auditDecisions { - assertResourceDecision(t, tt.expectedAuditDecisions[i], auditDecisions[i], i, "audit") - } - }) - } -} - -func Test_getResourceDecisionsWithObligations_ImmutabilityCheck(t *testing.T) { - originalDecision := &Decision{ - Results: []ResourceDecision{mkResourceDecision(testResource1ID, testResource1Name, true)}, - } - - obligationDecision := obligations.ObligationPolicyDecision{ - AllObligationsSatisfied: false, - RequiredObligationValueFQNs: []string{testObligation1FQN}, - RequiredObligationValueFQNsPerResource: []obligations.PerResourceDecision{mkPerResourceDecision(false, testObligation1FQN)}, - } - - resultDecision, auditDecisions := getResourceDecisionsWithObligations(originalDecision, obligationDecision) - - require.Len(t, resultDecision.Results, 1) - assert.False(t, resultDecision.Results[0].Passed) - assert.True(t, resultDecision.Results[0].Entitled) - assert.False(t, resultDecision.Results[0].ObligationsSatisfied) - assert.Equal(t, []string{testObligation1FQN}, resultDecision.Results[0].RequiredObligationValueFQNs) - - require.Len(t, auditDecisions, 1) - assert.Equal(t, resultDecision.Results[0], auditDecisions[0]) - - // Modifying the returned decision's obligation list should not affect the audit snapshot - resultDecision.Results[0].RequiredObligationValueFQNs = append(resultDecision.Results[0].RequiredObligationValueFQNs, testObligation2FQN) - - assert.Len(t, auditDecisions[0].RequiredObligationValueFQNs, 1) - assert.Equal(t, testObligation1FQN, auditDecisions[0].RequiredObligationValueFQNs[0]) - assert.Len(t, resultDecision.Results[0].RequiredObligationValueFQNs, 2) -} diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go index f5566ad3c5..c390ba8b0d 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -54,8 +54,8 @@ type ObligationPolicyDecision struct { AllObligationsSatisfied bool // The Set of obligations required across all resources in the decision RequiredObligationValueFQNs []string - // The Set of obligations required on each indexed resource - RequiredObligationValueFQNsPerResource []PerResourceDecision + // Individual decisions indexed to each resource + PerResourceDecisions []PerResourceDecision } func NewObligationsPolicyDecisionPoint( @@ -148,11 +148,11 @@ func (p *ObligationsPolicyDecisionPoint) GetAllTriggeredObligationsAreFulfilled( return ObligationPolicyDecision{}, err } - perResourceDecisions, allFulfilled := p.rollupResourceObligationDecisions(ctx, action, perResourceTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext) + perPerResourceDecisions, allFulfilled := p.rollupResourceObligationDecisions(ctx, action, perResourceTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext) return ObligationPolicyDecision{ - AllObligationsSatisfied: allFulfilled, - RequiredObligationValueFQNs: allTriggered, - RequiredObligationValueFQNsPerResource: perResourceDecisions, + AllObligationsSatisfied: allFulfilled, + RequiredObligationValueFQNs: allTriggered, + PerResourceDecisions: perPerResourceDecisions, }, nil } diff --git a/service/internal/access/v2/obligations/obligations_pdp_test.go b/service/internal/access/v2/obligations/obligations_pdp_test.go index 3dbaa1b2fa..144cf6c8a0 100644 --- a/service/internal/access/v2/obligations/obligations_pdp_test.go +++ b/service/internal/access/v2/obligations/obligations_pdp_test.go @@ -1219,7 +1219,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( decision, 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, decision.AllObligationsSatisfied, tt.name) - s.Equal(tt.wantPerResource, decision.RequiredObligationValueFQNsPerResource, tt.name) + s.Equal(tt.wantPerResource, decision.PerResourceDecisions, tt.name) s.Equal(tt.wantOverall, decision.RequiredObligationValueFQNs, tt.name) }) } diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 36641bb3ee..6e41c79873 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -70,7 +70,7 @@ var ( ErrMissingRequiredPolicy = errors.New("access: both attribute definitions and subject mappings must be provided or neither") ) -// PolicyDecisionPoint creates a new Policy Decision Point instance. +// NewPolicyDecisionPoint creates a new Policy Decision Point instance. // It is presumed that all Attribute Definitions and Subject Mappings are valid and contain the entirety of entitlement policy. // Attribute Values without Subject Mappings will be ignored in decisioning. func NewPolicyDecisionPoint(