diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 5a0409fa77..1aa2569a15 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -178,10 +178,10 @@ func (p *JustInTimePDP) GetDecision( hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 entitledWithAnyObligationsSatisfied := decision.AllPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) decision.AllPermitted = entitledWithAnyObligationsSatisfied - decision = setResourceDecisionsWithObligations(decision, obligationDecision) + decisionWithObligationsWhenEntitled, auditResourceDecisions := getResourceDecisionsWithObligations(decision, obligationDecision) - p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) - return []*Decision{decision}, decision.AllPermitted, nil + p.auditDecision(ctx, regResValueFQN, action, entitledWithAnyObligationsSatisfied, entitlements, fulfillableObligationValueFQNs, obligationDecision, auditResourceDecisions) + return []*Decision{decisionWithObligationsWhenEntitled}, entitledWithAnyObligationsSatisfied, nil default: return nil, false, ErrInvalidEntityType @@ -220,39 +220,55 @@ func (p *JustInTimePDP) GetDecision( // TODO: figure out this multi-entity response? // entitledWithAnyObligationsSatisfied := decision.AllPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) // decision.AllPermitted = entitledWithAnyObligationsSatisfied - decision = setResourceDecisionsWithObligations(decision, obligationDecision) + var auditResourceDecisions []ResourceDecision + decision, auditResourceDecisions = getResourceDecisionsWithObligations(decision, obligationDecision) decision.AllPermitted = allPermitted entityRepID := entityRepresentations[entityIdx].GetOriginalId() - p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) + p.auditDecision(ctx, entityRepID, action, allPermitted, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision, auditResourceDecisions) } return entityDecisions, allPermitted, nil } -// setResourceDecisionsWithObligations updates all resource decisions with obligation -// information and sets each resource passed state -func setResourceDecisionsWithObligations( +// 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 { +) (*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 { - // Update with specific obligation data from the obligations PDP perResource := obligationDecision.RequiredObligationValueFQNsPerResource[idx] resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied - resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs - } else { - // No required obligations means all obligations are satisfied - resourceDecision.ObligationsSatisfied = true + obligationFQNs = perResource.RequiredObligationValueFQNs + + // Only set obligations in response if entitled + if resourceDecision.Entitled { + resourceDecision.RequiredObligationValueFQNs = obligationFQNs + } } 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 + + return decision, auditResourceDecisions } // GetEntitlements retrieves the entitlements for the provided entity chain. @@ -427,19 +443,22 @@ func (p *JustInTimePDP) resolveEntitiesFromRequestToken( return p.resolveEntitiesFromToken(ctx, token, skipEnvironmentEntities) } -// auditDecision logs a GetDecisionV2 audit event with obligation information +// auditDecision logs a GetDecisionV2 audit event with obligation information. +// The auditResourceDecisions parameter should contain the full obligation context including +// for non-entitled resources, which is intentionally excluded from the actual response. func (p *JustInTimePDP) auditDecision( ctx context.Context, entityID string, action *policy.Action, - decision *Decision, + allPermitted bool, entitlements map[string][]*policy.Action, fulfillableObligationValueFQNs []string, obligationDecision obligations.ObligationPolicyDecision, + auditResourceDecisions []ResourceDecision, ) { // Determine audit decision result auditDecision := audit.GetDecisionResultDeny - if decision.AllPermitted { + if allPermitted { auditDecision = audit.GetDecisionResultPermit } @@ -450,6 +469,6 @@ func (p *JustInTimePDP) auditDecision( Entitlements: entitlements, FulfillableObligationValueFQNs: fulfillableObligationValueFQNs, ObligationsSatisfied: obligationDecision.AllObligationsSatisfied, - ResourceDecisions: decision.Results, + ResourceDecisions: auditResourceDecisions, }) } diff --git a/service/internal/access/v2/just_in_time_pdp_test.go b/service/internal/access/v2/just_in_time_pdp_test.go new file mode 100644 index 0000000000..9f9f6a3689 --- /dev/null +++ b/service/internal/access/v2/just_in_time_pdp_test.go @@ -0,0 +1,296 @@ +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) +}