diff --git a/service/authorization/v2/authorization_test.go b/service/authorization/v2/authorization_test.go index dc1f3ba98..bbb4ec38e 100644 --- a/service/authorization/v2/authorization_test.go +++ b/service/authorization/v2/authorization_test.go @@ -1374,7 +1374,7 @@ func Test_RollupSingleResourceDecision(t *testing.T) { permitted: true, decisions: []*access.Decision{ { - Access: true, + AllPermitted: true, Results: []access.ResourceDecision{ { ResourceID: "resource-123", @@ -1395,7 +1395,7 @@ func Test_RollupSingleResourceDecision(t *testing.T) { permitted: true, decisions: []*access.Decision{ { - Access: true, + AllPermitted: true, Results: []access.ResourceDecision{ { ResourceID: "resource-123", @@ -1422,7 +1422,7 @@ func Test_RollupSingleResourceDecision(t *testing.T) { permitted: false, decisions: []*access.Decision{ { - Access: true, // Verify permitted takes precedence + AllPermitted: true, // Verify permitted takes precedence Results: []access.ResourceDecision{ { ResourceID: "resource-123", @@ -1443,7 +1443,7 @@ func Test_RollupSingleResourceDecision(t *testing.T) { permitted: false, decisions: []*access.Decision{ { - Access: true, // Verify permitted takes precedence + AllPermitted: true, // Verify permitted takes precedence Results: []access.ResourceDecision{ { ResourceID: "resource-123", @@ -1473,8 +1473,8 @@ func Test_RollupSingleResourceDecision(t *testing.T) { permitted: true, decisions: []*access.Decision{ { - Access: true, - Results: []access.ResourceDecision{}, + AllPermitted: true, + Results: []access.ResourceDecision{}, }, }, expectedResult: nil, @@ -1509,7 +1509,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { name: "should return multiple permit decisions", decisions: []*access.Decision{ { - Access: true, + AllPermitted: true, Results: []access.ResourceDecision{ { Passed: true, @@ -1518,7 +1518,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, }, { - Access: true, + AllPermitted: true, Results: []access.ResourceDecision{ { Passed: true, @@ -1542,7 +1542,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { name: "should return mix of permit and deny decisions", decisions: []*access.Decision{ { - Access: true, + AllPermitted: true, Results: []access.ResourceDecision{ { Passed: true, @@ -1551,7 +1551,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, }, { - Access: false, + AllPermitted: false, Results: []access.ResourceDecision{ { Passed: false, @@ -1575,7 +1575,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { name: "should rely on results and default to false decisions", decisions: []*access.Decision{ { - Access: true, + AllPermitted: true, Results: []access.ResourceDecision{ { Passed: true, @@ -1588,7 +1588,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, }, { - Access: false, + AllPermitted: false, Results: []access.ResourceDecision{ { Passed: false, @@ -1616,7 +1616,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { name: "should ignore global access and care about resource decisions predominantly", decisions: []*access.Decision{ { - Access: false, + AllPermitted: false, Results: []access.ResourceDecision{ { Passed: false, @@ -1629,7 +1629,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, }, { - Access: false, + AllPermitted: false, Results: []access.ResourceDecision{ { Passed: true, @@ -1657,7 +1657,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { name: "should return obligations whenever found on a resource", decisions: []*access.Decision{ { - Access: true, + AllPermitted: true, Results: []access.ResourceDecision{ { Passed: true, @@ -1678,7 +1678,7 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { }, }, { - Access: false, + AllPermitted: false, Results: []access.ResourceDecision{ { Passed: false, @@ -1728,8 +1728,8 @@ func Test_RollupMultiResourceDecisions(t *testing.T) { name: "should return error when decision has no results", decisions: []*access.Decision{ { - Access: true, - Results: []access.ResourceDecision{}, + AllPermitted: true, + Results: []access.ResourceDecision{}, }, }, expectedError: ErrDecisionMustHaveResults, @@ -1794,8 +1794,8 @@ func Test_RollupMultiResourceDecisions_WithNilChecks(t *testing.T) { t.Run("nil Results field", func(t *testing.T) { decisions := []*access.Decision{ { - Access: true, - Results: nil, + AllPermitted: true, + Results: nil, }, } _, err := rollupMultiResourceDecisions(decisions) @@ -1822,8 +1822,8 @@ func Test_RollupSingleResourceDecision_WithNilChecks(t *testing.T) { t.Run("nil Results field", func(t *testing.T) { decisions := []*access.Decision{ { - Access: true, - Results: nil, + AllPermitted: true, + Results: nil, }, } _, err := rollupSingleResourceDecision(true, decisions) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 50067158b..ec9109dac 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -85,7 +85,7 @@ func getResourceDecision( // indicates a failure before attribute definition rule evaluation if len(resourceAttributeValues.GetFqns()) == 0 { failure := &ResourceDecision{ - Passed: false, + Entitled: false, ResourceID: resourceID, ResourceName: registeredResourceValueFQN, } @@ -151,7 +151,7 @@ func evaluateResourceAttributeValues( // Return results in the appropriate structure result := &ResourceDecision{ - Passed: passed, + Entitled: passed, ResourceID: resourceID, DataRuleResults: dataRuleResults, } diff --git a/service/internal/access/v2/evaluate_test.go b/service/internal/access/v2/evaluate_test.go index 468f225aa..d95e45160 100644 --- a/service/internal/access/v2/evaluate_test.go +++ b/service/internal/access/v2/evaluate_test.go @@ -785,7 +785,7 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() { } else { s.Require().NoError(err) s.NotNil(resourceDecision) - s.Equal(tc.expectAccessible, resourceDecision.Passed) + s.Equal(tc.expectAccessible, resourceDecision.Entitled) // Check results array has the correct length based on grouping by definition definitions := make(map[string]bool) @@ -937,7 +937,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() { } else { s.Require().NoError(err) s.NotNil(decision) - s.Equal(tc.expectPass, decision.Passed, "Decision pass status didn't match") + s.Equal(tc.expectPass, decision.Entitled, "Decision entitlement status didn't match") s.Equal(tc.resource.GetEphemeralId(), decision.ResourceID, "Resource ID didn't match") } }) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 6e019fb71..5a0409fa7 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -19,6 +19,7 @@ import ( "github.com/opentdf/platform/service/internal/access/v2/obligations" "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/logger/audit" ) var ( @@ -141,7 +142,7 @@ func (p *JustInTimePDP) GetDecision( ) // Because there are three possible types of entities, check obligations first to more easily handle decisioning logic - allTriggeredObligationsCanBeFulfilled, requiredObligationsPerResource, err := p.obligationsPDP.GetAllTriggeredObligationsAreFulfilled( + obligationDecision, err := p.obligationsPDP.GetAllTriggeredObligationsAreFulfilled( ctx, resources, action, @@ -165,7 +166,7 @@ func (p *JustInTimePDP) GetDecision( case *authzV2.EntityIdentifier_RegisteredResourceValueFqn: regResValueFQN := strings.ToLower(entityIdentifier.GetRegisteredResourceValueFqn()) // Registered resources do not have entity representations, so only one decision is made - decision, err := p.pdp.GetDecisionRegisteredResource(ctx, regResValueFQN, action, resources) + 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) } @@ -173,18 +174,14 @@ func (p *JustInTimePDP) GetDecision( return nil, false, fmt.Errorf("decision is nil for registered resource value FQN [%s]", regResValueFQN) } - // If not entitled, obligations are not considered - if !decision.Access { - return []*Decision{decision}, decision.Access, nil - } - - // Access should only be granted if entitled AND obligations fulfilled - decision.Access = allTriggeredObligationsCanBeFulfilled - for idx, required := range requiredObligationsPerResource { - decision.Results[idx].RequiredObligationValueFQNs = required - } + // Update resource decisions with obligations and set final access decision + hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 + entitledWithAnyObligationsSatisfied := decision.AllPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) + decision.AllPermitted = entitledWithAnyObligationsSatisfied + decision = setResourceDecisionsWithObligations(decision, obligationDecision) - return []*Decision{decision}, decision.Access, nil + p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) + return []*Decision{decision}, decision.AllPermitted, nil default: return nil, false, ErrInvalidEntityType @@ -195,38 +192,69 @@ func (p *JustInTimePDP) GetDecision( // Make initial entitlement decisions entityDecisions := make([]*Decision, len(entityRepresentations)) + entityEntitlements := make([]map[string][]*policy.Action, len(entityRepresentations)) allPermitted := true for idx, entityRep := range entityRepresentations { - d, err := p.pdp.GetDecision(ctx, entityRep, action, resources) + d, 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) } if d == nil { return nil, false, fmt.Errorf("decision is nil: %w", err) } - if !d.Access { + // If any entity lacks access to any resource, update overall decision denial + if !d.AllPermitted { allPermitted = false } entityDecisions[idx] = d + entityEntitlements[idx] = entitlements } - // If even one entity was denied access, obligations are not considered or returned - if !allPermitted { - return entityDecisions, allPermitted, nil - } + // Update resource decisions with obligations and set final access decision + hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 + allEntitledWithAnyObligationsSatisfied := allPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) + allPermitted = allEntitledWithAnyObligationsSatisfied - // Access should only be granted if entitled AND obligations fulfilled - allPermitted = allTriggeredObligationsCanBeFulfilled - // Obligations are not entity-specific at this time so will be the same across every entity - for _, decision := range entityDecisions { - for idx, required := range requiredObligationsPerResource { - decision.Results[idx].RequiredObligationValueFQNs = required - } + // 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 + decision = setResourceDecisionsWithObligations(decision, obligationDecision) + decision.AllPermitted = allPermitted + entityRepID := entityRepresentations[entityIdx].GetOriginalId() + p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) } return entityDecisions, allPermitted, nil } +// setResourceDecisionsWithObligations updates all resource decisions with obligation +// information and sets each resource passed state +func setResourceDecisionsWithObligations( + decision *Decision, + obligationDecision obligations.ObligationPolicyDecision, +) *Decision { + hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 + + for idx := range decision.Results { + resourceDecision := &decision.Results[idx] + + 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 + } + + resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + } + return decision +} + // 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. func (p *JustInTimePDP) GetEntitlements( @@ -287,8 +315,6 @@ func (p *JustInTimePDP) GetEntitlements( func (p *JustInTimePDP) getMatchedSubjectMappings( ctx context.Context, entityRepresentations []*entityresolutionV2.EntityRepresentation, - // updated with the results, attrValue FQN to attribute and value with subject mappings - // entitleableAttributes map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, ) ([]*policy.SubjectMapping, error) { // Break the entity down the entities into their properties/selectors and retrieve only those subject mappings subjectProperties := make([]*policy.SubjectProperty, 0) @@ -400,3 +426,30 @@ func (p *JustInTimePDP) resolveEntitiesFromRequestToken( return p.resolveEntitiesFromToken(ctx, token, skipEnvironmentEntities) } + +// auditDecision logs a GetDecisionV2 audit event with obligation information +func (p *JustInTimePDP) auditDecision( + ctx context.Context, + entityID string, + action *policy.Action, + decision *Decision, + entitlements map[string][]*policy.Action, + fulfillableObligationValueFQNs []string, + obligationDecision obligations.ObligationPolicyDecision, +) { + // Determine audit decision result + auditDecision := audit.GetDecisionResultDeny + if decision.AllPermitted { + auditDecision = audit.GetDecisionResultPermit + } + + p.logger.Audit.GetDecisionV2(ctx, audit.GetDecisionV2EventParams{ + EntityID: entityID, + ActionName: action.GetName(), + Decision: auditDecision, + Entitlements: entitlements, + FulfillableObligationValueFQNs: fulfillableObligationValueFQNs, + ObligationsSatisfied: obligationDecision.AllObligationsSatisfied, + ResourceDecisions: decision.Results, + }) +} diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go index 2acb23c97..eed1c752f 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -43,6 +43,22 @@ type ObligationsPolicyDecisionPoint struct { clientIDScopedTriggerActionsToAttributes map[string]obligationValuesByActionOnAnAttributeValue } +type PerResourceDecision struct { + // Whether or not all obligations triggered for the resource can be fulfilled by the caller + ObligationsSatisfied bool + // The Set of obligations required on this indexed resource + RequiredObligationValueFQNs []string +} + +type ObligationPolicyDecision struct { + // Whether or not all the obligations that were triggered can be fulfilled by the caller + 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 +} + func NewObligationsPolicyDecisionPoint( ctx context.Context, l *logger.Logger, @@ -120,38 +136,40 @@ func NewObligationsPolicyDecisionPoint( // 4. the obligation value FQNs a PEP is capable of fulfilling (self-reported) // // It will check the action, resources, and decision request context for the obligation values triggered, -// compare the PEP fulfillable obligations against those that have been triggered as required, -// and return whether or not all triggered obligations can be fulfilled along with the set of obligation FQNs -// the PEP must fulfill for each resource in the provided list. +// then compare the PEP fulfillable obligations against those that have been triggered as required. func (p *ObligationsPolicyDecisionPoint) GetAllTriggeredObligationsAreFulfilled( ctx context.Context, resources []*authz.Resource, action *policy.Action, decisionRequestContext *policy.RequestContext, pepFulfillableObligationValueFQNs []string, -) (bool, [][]string, error) { - perResource, allTriggered, err := p.getTriggeredObligations(ctx, action, resources, decisionRequestContext) +) (ObligationPolicyDecision, error) { + perResourceTriggered, allTriggered, err := p.getTriggeredObligations(ctx, action, resources, decisionRequestContext) if err != nil { - return false, nil, err + return ObligationPolicyDecision{}, err } - allFulfilled := p.getAllObligationsAreFulfilled(ctx, action, allTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext) - return allFulfilled, perResource, nil + perResourceDecisions, allFulfilled := p.rollupResourceObligationDecisions(ctx, action, perResourceTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext) + return ObligationPolicyDecision{ + AllObligationsSatisfied: allFulfilled, + RequiredObligationValueFQNs: allTriggered, + RequiredObligationValueFQNsPerResource: perResourceDecisions, + }, nil } -// getAllObligationsAreFulfilled checks the deduplicated list of triggered obligations against the PEP -// self-reported fulfillable obligations to validate the PEP can fulfill all that were triggered. +// rollupResourceObligationDecisions checks the per-resource list of triggered obligations against the PEP +// self-reported fulfillable obligations to validate the PEP can fulfill those triggered on each resource // // While this is a simple check now, enhancements in types of obligations and the fulfillment source of truth // (such as a PEP registration or centralized config) will add complexity to this validation. The RequestContext // itself may sometimes contain information that may fulfill the obligation in the future. -func (p *ObligationsPolicyDecisionPoint) getAllObligationsAreFulfilled( +func (p *ObligationsPolicyDecisionPoint) rollupResourceObligationDecisions( ctx context.Context, action *policy.Action, - allTriggeredObligationValueFQNs []string, + perResourceTriggeredObligationValueFQNs [][]string, pepFulfillableObligationValueFQNs []string, decisionRequestContext *policy.RequestContext, -) bool { +) ([]PerResourceDecision, bool) { log := loggerWithAttributes(p.logger, strings.ToLower(action.GetName()), decisionRequestContext.GetPep().GetClientId()) fulfillable := make(map[string]struct{}) @@ -160,29 +178,42 @@ func (p *ObligationsPolicyDecisionPoint) getAllObligationsAreFulfilled( fulfillable[obligation] = struct{}{} } + unfulfilledSeen := make(map[string]struct{}) var unfulfilled []string - for _, obligated := range allTriggeredObligationValueFQNs { - obligated = strings.ToLower(obligated) - if _, found := fulfillable[obligated]; !found { - unfulfilled = append(unfulfilled, obligated) + results := make([]PerResourceDecision, len(perResourceTriggeredObligationValueFQNs)) + for i, resourceTriggeredObligations := range perResourceTriggeredObligationValueFQNs { + resourceSatisfied := true + for _, triggered := range resourceTriggeredObligations { + triggered = strings.ToLower(triggered) + if _, ok := fulfillable[triggered]; !ok { + if _, seen := unfulfilledSeen[triggered]; !seen { + unfulfilledSeen[triggered] = struct{}{} + unfulfilled = append(unfulfilled, triggered) + } + resourceSatisfied = false + } + } + results[i] = PerResourceDecision{ + ObligationsSatisfied: resourceSatisfied, + RequiredObligationValueFQNs: resourceTriggeredObligations, } } if len(unfulfilled) > 0 { log.DebugContext( ctx, - "found unfulfilled obligations that cannot be fulfilled by PEP", + "found triggered obligations not reported as fulfillable", slog.Any("unfulfilled_obligations", unfulfilled), ) - return false + return results, false } log.DebugContext( ctx, - "all triggered obligations can be fulfilled by PEP", + "any triggered obligations reported as fulfillable", ) - return true + return results, true } // getTriggeredObligations takes in an action and multiple resources subject to decisioning. @@ -302,7 +333,7 @@ func (p *ObligationsPolicyDecisionPoint) getTriggeredObligations( log.DebugContext( ctx, - "found required obligations", + "checked required obligations", slog.Any("deduplicated_request_obligations_across_all_resources", allRequiredOblValueFQNs), ) log.TraceContext( diff --git a/service/internal/access/v2/obligations/obligations_pdp_test.go b/service/internal/access/v2/obligations/obligations_pdp_test.go index d8111677b..658753c03 100644 --- a/service/internal/access/v2/obligations/obligations_pdp_test.go +++ b/service/internal/access/v2/obligations/obligations_pdp_test.go @@ -776,103 +776,245 @@ func (s *ObligationsPDPSuite) Test_getTriggeredObligations_CustomAction_MixedRes s.Contains(all, mockObligationFQN4) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_MoreFulfilledThanTriggered() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3} - - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) -} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_AllResourcesFulfilled() { + perResourceTriggered := [][]string{ + {mockObligationFQN1}, + {mockObligationFQN2}, + } + pepFulfillable := []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3} -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_ExactMatch() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN2, mockObligationFQN1} + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + s.True(allFulfilled) + s.Len(perResource, 2) + s.True(perResource[0].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN1}, perResource[0].RequiredObligationValueFQNs) + s.True(perResource[1].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN2}, perResource[1].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_CasingMismatchFQNs() { - allTriggeredObligationValueFQNs := []string{strings.ToUpper(mockObligationFQN1), mockObligationFQN2} - pepFulfillableObligationValueFQNs := []string{strings.ToUpper(mockObligationFQN2), mockObligationFQN1} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_SomeResourcesUnfulfilled() { + perResourceTriggered := [][]string{ + {mockObligationFQN1}, + {mockObligationFQN2}, + {mockObligationFQN3}, + } + pepFulfillable := []string{mockObligationFQN1, mockObligationFQN2} - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) + + s.False(allFulfilled) + s.Len(perResource, 3) + s.True(perResource[0].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN1}, perResource[0].RequiredObligationValueFQNs) + s.True(perResource[1].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN2}, perResource[1].RequiredObligationValueFQNs) + s.False(perResource[2].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN3}, perResource[2].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_MissingObligation() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN3} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN1} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_CasingMismatch() { + perResourceTriggered := [][]string{ + {strings.ToUpper(mockObligationFQN1)}, + {mockObligationFQN2}, + } + pepFulfillable := []string{strings.ToUpper(mockObligationFQN2), mockObligationFQN1} - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - s.False(fulfilled) + s.True(allFulfilled) + s.Len(perResource, 2) + s.True(perResource[0].ObligationsSatisfied) + s.True(perResource[1].ObligationsSatisfied) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_EmptyTriggered() { - allTriggeredObligationValueFQNs := []string{} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_EmptyTriggered() { + perResourceTriggered := [][]string{{}, {}} + pepFulfillable := []string{mockObligationFQN1, mockObligationFQN2} - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) + + s.True(allFulfilled) + s.Len(perResource, 2) + s.True(perResource[0].ObligationsSatisfied) + s.Empty(perResource[0].RequiredObligationValueFQNs) + s.True(perResource[1].ObligationsSatisfied) + s.Empty(perResource[1].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_EmptyFulfillable() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1} - pepFulfillableObligationValueFQNs := []string{} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_EmptyFulfillable() { + perResourceTriggered := [][]string{ + {mockObligationFQN1}, + } + pepFulfillable := []string{} - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - s.False(fulfilled) + s.False(allFulfilled) + s.Len(perResource, 1) + s.False(perResource[0].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN1}, perResource[0].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_BothEmpty() { - allTriggeredObligationValueFQNs := []string{} - pepFulfillableObligationValueFQNs := []string{} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_NoResources() { + perResourceTriggered := [][]string{} + pepFulfillable := []string{mockObligationFQN1} + + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + s.True(allFulfilled) + s.Empty(perResource) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_SingleObligation_Fulfilled() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN1} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_SingleResourceFulfilled() { + perResourceTriggered := [][]string{ + {mockObligationFQN1}, + } + pepFulfillable := []string{mockObligationFQN1} + + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + s.True(allFulfilled) + s.Len(perResource, 1) + s.True(perResource[0].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN1}, perResource[0].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_SingleObligation_NotFulfilled() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN3} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN2} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_SingleResourceUnfulfilled() { + perResourceTriggered := [][]string{ + {mockObligationFQN3}, + } + pepFulfillable := []string{mockObligationFQN2} - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - s.False(fulfilled) + s.False(allFulfilled) + s.Len(perResource, 1) + s.False(perResource[0].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN3}, perResource[0].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_DuplicateTriggered() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN1, mockObligationFQN2} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_MultipleObligationsPerResource() { + perResourceTriggered := [][]string{ + {mockObligationFQN1, mockObligationFQN2}, + {mockObligationFQN3, mockObligationFQN4}, + } + pepFulfillable := []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3, mockObligationFQN4} + + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + s.True(allFulfilled) + s.Len(perResource, 2) + s.True(perResource[0].ObligationsSatisfied) + s.ElementsMatch([]string{mockObligationFQN1, mockObligationFQN2}, perResource[0].RequiredObligationValueFQNs) + s.True(perResource[1].ObligationsSatisfied) + s.ElementsMatch([]string{mockObligationFQN3, mockObligationFQN4}, perResource[1].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_DuplicateFulfillable() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN1, mockObligationFQN2, mockObligationFQN2} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_MixedFulfillment() { + perResourceTriggered := [][]string{ + {mockObligationFQN1}, + {mockObligationFQN2, mockObligationFQN3}, + {mockObligationFQN4}, + } + pepFulfillable := []string{mockObligationFQN1, mockObligationFQN2} + + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + emptyDecisionRequestContext, + ) - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + s.False(allFulfilled) + s.Len(perResource, 3) + s.True(perResource[0].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN1}, perResource[0].RequiredObligationValueFQNs) + s.False(perResource[1].ObligationsSatisfied) + s.ElementsMatch([]string{mockObligationFQN2, mockObligationFQN3}, perResource[1].RequiredObligationValueFQNs) + s.False(perResource[2].ObligationsSatisfied) + s.Equal([]string{mockObligationFQN4}, perResource[2].RequiredObligationValueFQNs) } -func (s *ObligationsPDPSuite) Test_getAllObligationsAreFulfilled_AllObligations_Fulfilled() { - allTriggeredObligationValueFQNs := []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3, mockObligationFQN4} - pepFulfillableObligationValueFQNs := []string{mockObligationFQN4, mockObligationFQN3, mockObligationFQN2, mockObligationFQN1} +func (s *ObligationsPDPSuite) Test_rollupResourceObligationDecisions_WithClientID() { + perResourceTriggered := [][]string{ + {mockObligationFQN1}, + {mockObligationFQN2}, + } + pepFulfillable := []string{mockObligationFQN1, mockObligationFQN2} + decisionRequestContext := &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + } + + perResource, allFulfilled := s.pdp.rollupResourceObligationDecisions( + s.T().Context(), + actionRead, + perResourceTriggered, + pepFulfillable, + decisionRequestContext, + ) - fulfilled := s.pdp.getAllObligationsAreFulfilled(s.T().Context(), actionRead, allTriggeredObligationValueFQNs, pepFulfillableObligationValueFQNs, emptyDecisionRequestContext) - s.True(fulfilled) + s.True(allFulfilled) + s.Len(perResource, 2) + s.True(perResource[0].ObligationsSatisfied) + s.True(perResource[1].ObligationsSatisfied) } func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke() { @@ -886,7 +1028,8 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( name string args args wantAllFulfilled bool - wantPerResource [][]string + wantPerResource []PerResourceDecision + wantOverall []string }{ { name: "fulfilled - attributes", @@ -900,11 +1043,33 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( }, }, }, + { + Resource: &authz.Resource_AttributeValues_{ + AttributeValues: &authz.Resource_AttributeValues{ + Fqns: []string{mockAttrValFQN2}, + }, + }, + }, }, - pepFulfillable: []string{mockObligationFQN1}, + decisionRequestContext: &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: mockClientID, + }, + }, + pepFulfillable: []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3}, }, wantAllFulfilled: true, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN2}, + }, + }, + wantOverall: []string{mockObligationFQN1, mockObligationFQN2}, }, { name: "fulfilled - registered resource", @@ -920,7 +1085,13 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN1}, }, wantAllFulfilled: true, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + }, + wantOverall: []string{mockObligationFQN1}, }, { name: "fulfilled - registered resource client scoped", @@ -941,7 +1112,13 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN2}, }, wantAllFulfilled: true, - wantPerResource: [][]string{{mockObligationFQN2}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN2}, + }, + }, + wantOverall: []string{mockObligationFQN2}, }, { name: "fulfilled - casing mismatches", @@ -961,7 +1138,13 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{strings.ToUpper(mockObligationFQN1)}, }, wantAllFulfilled: true, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + }, + wantOverall: []string{mockObligationFQN1}, }, { name: "unfulfilled - attributes", @@ -979,7 +1162,13 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN2}, }, wantAllFulfilled: false, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: false, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + }, + wantOverall: []string{mockObligationFQN1}, }, { name: "unfulfilled - registered resource", @@ -995,7 +1184,13 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN2}, }, wantAllFulfilled: false, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: false, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + }, + wantOverall: []string{mockObligationFQN1}, }, { name: "no obligations triggered", @@ -1012,15 +1207,21 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( }, }, wantAllFulfilled: true, - wantPerResource: [][]string{{}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{}, + }, + }, }, } for _, tt := range tests { s.T().Run(tt.name, func(t *testing.T) { - gotAllFulfilled, gotPerResource, err := s.pdp.GetAllTriggeredObligationsAreFulfilled(t.Context(), tt.args.resources, tt.args.action, tt.args.decisionRequestContext, tt.args.pepFulfillable) + 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, gotAllFulfilled, tt.name) - s.Equal(tt.wantPerResource, gotPerResource, tt.name) + s.Equal(tt.wantAllFulfilled, decision.AllObligationsSatisfied, tt.name) + s.Equal(tt.wantPerResource, decision.RequiredObligationValueFQNsPerResource, 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 1406bd431..dffb36383 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -15,18 +15,24 @@ import ( attrs "github.com/opentdf/platform/protocol/go/policy/attributes" "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" "github.com/opentdf/platform/service/logger" - "github.com/opentdf/platform/service/logger/audit" ) // Decision represents the overall access decision for an entity. type Decision struct { - Access bool `json:"access" example:"false"` - Results []ResourceDecision `json:"entity_rule_result"` + // AllPermitted means all entities requesting to take the action on the resource(s) were entitled + // and that any triggered obligations were satisfied by those reported as fulfillable. + // The struct tag remains 'access' for backwards compatibility within audit records. + AllPermitted bool `json:"access" example:"false"` + Results []ResourceDecision } // ResourceDecision represents the result of evaluating the action on one resource for an entity. type ResourceDecision struct { - Passed bool `json:"passed" example:"false"` + // An overall result representing a roll-up of ObligationsSatisfied && Entitled + Passed bool `json:"passed" example:"false"` + // FulfillableObligations >= TriggeredObligations + ObligationsSatisfied bool `json:"obligations_satisfied" example:"false"` + Entitled bool `json:"entitled" example:"false"` ResourceID string `json:"resource_id,omitempty"` ResourceName string `json:"resource_name,omitempty"` DataRuleResults []DataRuleResult `json:"data_rule_results"` @@ -162,74 +168,61 @@ func NewPolicyDecisionPoint( return pdp, nil } -// GetDecision evaluates the action on the resources for the entity and returns a decision. +// GetDecision evaluates the action on the resources for the entity and returns a decision along with entitlements. func (p *PolicyDecisionPoint) GetDecision( ctx context.Context, entityRepresentation *entityresolutionV2.EntityRepresentation, action *policy.Action, resources []*authz.Resource, -) (*Decision, error) { +) (*Decision, map[string][]*policy.Action, error) { l := p.logger.With("entity_id", entityRepresentation.GetOriginalId()) l = l.With("action", action.GetName()) l.DebugContext(ctx, "getting decision", slog.Int("resources_count", len(resources))) if err := validateGetDecision(entityRepresentation, action, resources); err != nil { - return nil, err + return nil, nil, err } // Filter all attributes down to only those that relevant to the entitlement decisioning of these specific resources decisionableAttributes, err := getResourceDecisionableAttributes(ctx, l, p.allRegisteredResourceValuesByFQN, p.allEntitleableAttributesByValueFQN /* action, */, resources) if err != nil { - return nil, fmt.Errorf("error getting decisionable attributes: %w", err) + return nil, nil, fmt.Errorf("error getting decisionable attributes: %w", err) } l.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable_attribute_values_count", len(decisionableAttributes))) // Resolve them to their entitled FQNs and the actions available on each entitledFQNsToActions, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(decisionableAttributes, entityRepresentation) if err != nil { - return nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) + return nil, nil, fmt.Errorf("error evaluating subject mappings for entitlement: %w", err) } l.DebugContext(ctx, "evaluated subject mappings", slog.Any("entitled_value_fqns_to_actions", entitledFQNsToActions)) decision := &Decision{ - Access: true, - Results: make([]ResourceDecision, len(resources)), + AllPermitted: true, + Results: make([]ResourceDecision, len(resources)), } for idx, resource := range resources { resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource) if err != nil || resourceDecision == nil { - return nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) + return nil, nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } - if !resourceDecision.Passed { - decision.Access = false + if !resourceDecision.Entitled { + decision.AllPermitted = false } l.DebugContext( ctx, "resourceDecision result", - slog.Bool("passed", resourceDecision.Passed), + slog.Bool("entitled", resourceDecision.Entitled), slog.String("resource_id", resourceDecision.ResourceID), slog.Int("data_rule_results_count", len(resourceDecision.DataRuleResults)), ) decision.Results[idx] = *resourceDecision } - auditDecision := audit.GetDecisionResultDeny - if decision.Access { - auditDecision = audit.GetDecisionResultPermit - } - - l.Audit.GetDecisionV2(ctx, audit.GetDecisionV2EventParams{ - EntityID: entityRepresentation.GetOriginalId(), - ActionName: action.GetName(), - Decision: auditDecision, - Entitlements: entitledFQNsToActions, - ResourceDecisions: decision.Results, - }) - - return decision, nil + return decision, entitledFQNsToActions, nil } func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( @@ -237,24 +230,24 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( entityRegisteredResourceValueFQN string, action *policy.Action, resources []*authz.Resource, -) (*Decision, error) { +) (*Decision, map[string][]*policy.Action, error) { l := p.logger.With("entity_registered_resource_value_fqn", entityRegisteredResourceValueFQN) l = l.With("action", action.GetName()) l.DebugContext(ctx, "getting decision", slog.Int("resources_count", len(resources))) if err := validateGetDecisionRegisteredResource(entityRegisteredResourceValueFQN, action, resources); err != nil { - return nil, err + return nil, nil, err } entityRegisteredResourceValue, ok := p.allRegisteredResourceValuesByFQN[entityRegisteredResourceValueFQN] if !ok { - return nil, fmt.Errorf("registered resource value FQN not found in memory [%s]: %w", entityRegisteredResourceValueFQN, ErrInvalidResource) + return nil, nil, fmt.Errorf("registered resource value FQN not found in memory [%s]: %w", entityRegisteredResourceValueFQN, ErrInvalidResource) } // Filter all attributes down to only those that relevant to the entitlement decisioning of these specific resources decisionableAttributes, err := getResourceDecisionableAttributes(ctx, l, p.allRegisteredResourceValuesByFQN, p.allEntitleableAttributesByValueFQN /*action, */, resources) if err != nil { - return nil, fmt.Errorf("error getting decisionable attributes: %w", err) + return nil, nil, fmt.Errorf("error getting decisionable attributes: %w", err) } l.DebugContext(ctx, "filtered to only entitlements relevant to decisioning", slog.Int("decisionable_attribute_values_count", len(decisionableAttributes))) @@ -283,30 +276,30 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( } decision := &Decision{ - Access: true, - Results: make([]ResourceDecision, len(resources)), + AllPermitted: true, + Results: make([]ResourceDecision, len(resources)), } for idx, resource := range resources { resourceDecision, err := getResourceDecision(ctx, l, decisionableAttributes, p.allRegisteredResourceValuesByFQN, entitledFQNsToActions, action, resource) if err != nil || resourceDecision == nil { - return nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) + return nil, nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } - if !resourceDecision.Passed { - decision.Access = false + if !resourceDecision.Entitled { + decision.AllPermitted = false } l.DebugContext( ctx, "resourceDecision result", - slog.Bool("passed", resourceDecision.Passed), + slog.Bool("entitled", resourceDecision.Entitled), slog.String("resource_id", resourceDecision.ResourceID), slog.Int("data_rule_results_count", len(resourceDecision.DataRuleResults)), ) decision.Results[idx] = *resourceDecision } - return decision, nil + return decision, entitledFQNsToActions, nil } func (p *PolicyDecisionPoint) GetEntitlements( diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 80ca8ce1c..0f8ed387c 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -892,11 +892,11 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { testClassSecretRegResFQN, testDeptEngineeringRegResFQN, ) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, entitlements, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -907,10 +907,20 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { } s.assertAllDecisionResults(decision, expectedResults) for _, result := range decision.Results { - s.True(result.Passed, "All data rules should pass") + s.True(result.Entitled, "All data rules should pass") s.Len(result.DataRuleResults, 1) s.Empty(result.DataRuleResults[0].EntitlementFailures) } + + // Verify entitlements are returned correctly + s.Require().NotNil(entitlements, "Entitlements should not be nil") + s.Contains(entitlements, testClassTopSecretFQN, "Should be entitled to topsecret classification based on clearance 'ts'") + s.Contains(entitlements, testDeptEngineeringFQN, "Should be entitled to engineering department") + + // Verify the testActionRead is in the entitled actions for these attribute values + s.Require().Contains(entitlements[testClassTopSecretFQN], testActionRead, "Should have read action for topsecret classification") + s.Require().Contains(entitlements[testDeptEngineeringFQN], testActionRead, "Should have read action for engineering department") + s.Require().Contains(entitlements[testDeptEngineeringFQN], testActionCreate, "Should have create action for engineering department") }) s.Run("Multiple resources and entitled actions/attributes of varied casing - full access", func() { @@ -923,11 +933,11 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { resources := createResourcePerFqn(secretFQN, testDeptEngineeringFQN, secretRegResFQN, testDeptEngineeringRegResFQN) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -938,7 +948,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { } s.assertAllDecisionResults(decision, expectedResults) for _, result := range decision.Results { - s.True(result.Passed, "All data rules should pass") + s.True(result.Entitled, "All data rules should pass") s.Len(result.DataRuleResults, 1) s.Empty(result.DataRuleResults[0].EntitlementFailures) } @@ -955,11 +965,11 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { testClassSecretRegResFQN, testDeptEngineeringRegResFQN, ) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionUpdate, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionUpdate, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -971,7 +981,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { s.assertAllDecisionResults(decision, expectedResults) for idx, result := range decision.Results { - s.False(result.Passed, "Data rules should not pass") + s.False(result.Entitled, "Data rules should not pass") // Only expect rule results if the rule was evaluated, which doesn't happen for early // failures within action-attribute-value mismatches with the requested action if idx < 3 { @@ -992,11 +1002,11 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { testDeptEngineeringRegResFQN, testClassSecretRegResFQN) // Get decision for delete action (not allowed by either attribute's subject mappings) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -1019,11 +1029,11 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { testClassSecretRegResFQN, testDeptFinanceRegResFQN, ) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // False because one resource is denied + s.False(decision.AllPermitted) // False because one resource is denied s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -1039,10 +1049,10 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { s.Len(result.DataRuleResults, 1) if result.ResourceID == testClassSecretFQN { - s.True(result.Passed, "Secret should pass") + s.True(result.Entitled, "Secret should pass") s.Empty(result.DataRuleResults[0].EntitlementFailures) } else if result.ResourceID == testDeptFinanceFQN { - s.False(result.Passed, "Finance should not pass") + s.False(result.Entitled, "Finance should not pass") s.NotEmpty(result.DataRuleResults[0].EntitlementFailures) } } @@ -1070,16 +1080,16 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { }, } - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 2) foundRnd := false foundTopSecret := false for _, result := range decision.Results { - s.True(result.Passed, "All registered resource value access requests should pass") + s.True(result.Entitled, "All registered resource value access requests should pass") s.Len(result.DataRuleResults, 1) s.Empty(result.DataRuleResults[0].EntitlementFailures) switch result.ResourceName { @@ -1118,16 +1128,16 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { }, } - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 2) foundRnd := false foundTopSecret := false for _, result := range decision.Results { - s.True(result.Passed, "All registered resource value access requests should pass") + s.True(result.Entitled, "All registered resource value access requests should pass") s.Len(result.DataRuleResults, 1) s.Empty(result.DataRuleResults[0].EntitlementFailures) switch result.ResourceName { @@ -1165,16 +1175,16 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { }, } - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 2) foundRnd := false foundTopSecret := false for _, result := range decision.Results { - s.False(result.Passed, "All registered resource access requests should fail") + s.False(result.Entitled, "All registered resource access requests should fail") s.Len(result.DataRuleResults, 1) s.NotEmpty(result.DataRuleResults[0].EntitlementFailures) switch result.ResourceName { @@ -1213,16 +1223,16 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { } unentitledAction := testActionDelete - decision, err := pdp.GetDecision(s.T().Context(), entity, unentitledAction, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, unentitledAction, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 2) foundRnd := false foundTopSecret := false for _, result := range decision.Results { - s.False(result.Passed, "All registered resource access requests should fail") + s.False(result.Entitled, "All registered resource access requests should fail") switch result.ResourceName { case rndDeptRegResFQN: foundRnd = true @@ -1259,10 +1269,10 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { } partiallyEntitledAction := testActionUpdate - decision, err := pdp.GetDecision(s.T().Context(), entity, partiallyEntitledAction, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, partiallyEntitledAction, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 2) foundRnd := false @@ -1306,10 +1316,10 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { }, } - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 2) foundRnd := false @@ -1335,6 +1345,61 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { }) } +func (s *PDPTestSuite) Test_GetDecision_ReturnsDecisionRelatedEntitlements() { + f := s.fixtures + + // Create PDP with test fixtures + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{f.classificationAttr, f.departmentAttr}, + []*policy.SubjectMapping{f.topSecretMapping, f.engineeringMapping}, + []*policy.RegisteredResource{}, + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + + entity := s.createEntityWithProps("test-user-entitlements", map[string]interface{}{ + "clearance": "ts", + "department": "engineering", + }) + + resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN) + + decision, entitlements, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.AllPermitted, "Entity should have access") + + s.Require().NotNil(entitlements, "Entitlements should not be nil") + + // The entitlement on the same attribute should be returned, but in this case, is hierarchically higher + s.Require().Contains(entitlements, testClassTopSecretFQN) + s.NotContains(entitlements, testClassSecretFQN) + + // The entity should be entitled to engineering department + s.Require().Contains(entitlements, testDeptEngineeringFQN) + + // Actions match expected + topsecretActions := entitlements[testClassTopSecretFQN] + s.Require().NotNil(topsecretActions) + s.Require().Len(topsecretActions, 1) + s.Equal(actions.ActionNameRead, topsecretActions[0].GetName()) + + engineeringActions := entitlements[testDeptEngineeringFQN] + s.Require().NotNil(engineeringActions) + s.Require().Len(engineeringActions, 2) + + // Check both read and create actions are present (order may vary) + actionNames := make(map[string]bool) + for _, action := range engineeringActions { + actionNames[action.GetName()] = true + } + s.True(actionNames[actions.ActionNameRead]) + s.True(actionNames[actions.ActionNameCreate]) +} + // Test_GetDecision_PartialActionEntitlement tests scenarios where actions only partially align with entitlements func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { f := s.fixtures @@ -1419,19 +1484,19 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { // Resource to evaluate resources := createResourcePerFqn(testClassSecretFQN, testClassSecretRegResFQN) - decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, actionRead, resources) // Read should pass s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) // Should be true because read is allowed + s.True(decision.AllPermitted) // Should be true because read is allowed s.Len(decision.Results, 2) // Create should fail - decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // Should be false because create is not allowed + s.False(decision.AllPermitted) // Should be false because create is not allowed s.Len(decision.Results, 2) }) @@ -1447,35 +1512,35 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { testDeptFinanceRegResResource := createRegisteredResource(testDeptFinanceRegResFQN, testDeptFinanceRegResFQN) // Test read access - should be allowed by all attributes - decision, err := pdp.GetDecision(s.T().Context(), entity, actionRead, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, actionRead, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 3) // Test create access - should be denied (confidential doesn't allow it) - decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // Overall access is denied + s.False(decision.AllPermitted) // Overall access is denied // Test print access - allowed by confidential but not by finance - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionPrint, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionPrint, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // Overall access is denied because one rule fails + s.False(decision.AllPermitted) // Overall access is denied because one rule fails // Test update access - allowed by finance but not by confidential - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // Overall access is denied because one rule fails + s.False(decision.AllPermitted) // Overall access is denied because one rule fails // Test delete access - denied by both - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{combinedResource, testClassConfidentialRegResResource, testDeptFinanceRegResResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) }) s.Run("Action inheritance with partial permissions", func() { @@ -1487,34 +1552,34 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { resources := createResourcePerFqn(testProjectAlphaFQN, testProjectAlphaRegResFQN) // Test view access - should be denied as view action not supported by registered resource - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionView, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionView, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test list access - should be denied - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test search access - should be denied - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionSearch, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionSearch, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test read access - should be allowed - decision, err = pdp.GetDecision(s.T().Context(), entity, actionRead, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, actionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // Test create access - should be allowed - decision, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, actionCreate, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) }) s.Run("Conflicting action policies across multiple attributes", func() { @@ -1563,22 +1628,22 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { restrictedResources := createResourcePerFqn(testClassConfidentialFQN, testClassConfidentialRegResFQN) // Test read access - should be allowed for restricted - decision, err := classificationPDP.GetDecision(s.T().Context(), entity, actionRead, restrictedResources) + decision, _, err := classificationPDP.GetDecision(s.T().Context(), entity, actionRead, restrictedResources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // Test create access - should be denied for restricted despite comprehensive actions on public - decision, err = classificationPDP.GetDecision(s.T().Context(), entity, actionCreate, restrictedResources) + decision, _, err = classificationPDP.GetDecision(s.T().Context(), entity, actionCreate, restrictedResources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test delete access - should be denied for restricted despite comprehensive actions on public - decision, err = classificationPDP.GetDecision(s.T().Context(), entity, testActionDelete, restrictedResources) + decision, _, err = classificationPDP.GetDecision(s.T().Context(), entity, testActionDelete, restrictedResources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) }) s.Run("Requested entitled action (on hierarchical attribute) not supported by registered resource fails", func() { @@ -1598,22 +1663,22 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { // Test print access - should be denied because RR action-attribute-value does not support it despite // entity's entitlement to the action on the attribute - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionPrint, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionPrint, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test unentitled action - should be denied - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test read access - should be allowed - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) }) s.Run("Requested entitled action (on any_of attribute) not supported by registered resource fails", func() { @@ -1633,22 +1698,22 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { // Test delete access - should be denied because RR action-attribute-value does not support it despite // entity's entitlement to the action on the attribute - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test unentitled action - should be denied - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionList, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Test read access - should be allowed - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) }) } @@ -1682,22 +1747,22 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() combinedResource := createAttributeValueResource("secret-engineering-resource", testClassSecretFQN, testDeptEngineeringFQN) // Test read access (both allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // Test create access (only engineering allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass + s.False(decision.AllPermitted) // False because both attributes need to pass // Test update access (only secret allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass + s.False(decision.AllPermitted) // False because both attributes need to pass }) s.Run("HIERARCHY + ALL_OF combined: Secret classification and USA country", func() { @@ -1711,16 +1776,16 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() combinedResource := createAttributeValueResource("secret-usa-resource", testClassSecretFQN, testCountryUSAFQN) // Test read access (both allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // Test update access (only secret allows, usa doesn't) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass + s.False(decision.AllPermitted) // False because both attributes need to pass }) s.Run("ANY_OF + ALL_OF combined: Engineering department and USA AND UK country", func() { @@ -1734,16 +1799,16 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() combinedResource := createAttributeValueResource("engineering-usa-uk-resource", testDeptEngineeringFQN, testCountryUSAFQN, testCountryUKFQN) // Test read access (both allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // Test create access (only engineering allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionCreate, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // False because both attributes need to pass + s.False(decision.AllPermitted) // False because both attributes need to pass }) s.Run("HIERARCHY + ANY_OF + ALL_OF combined - ALL_OF FAILURE", func() { @@ -1758,15 +1823,15 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() combinedResource := createAttributeValueResource("secret-engineering-usa-uk-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUKFQN, testCountryUSAFQN) // Test read access (all three allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 1) // Drill down proper structure of denial resourceDecision := decision.Results[0] - s.Require().False(resourceDecision.Passed) + s.Require().False(resourceDecision.Entitled) s.Equal("secret-engineering-usa-uk-resource", resourceDecision.ResourceID) s.Len(resourceDecision.DataRuleResults, 3) for _, ruleResult := range resourceDecision.DataRuleResults { @@ -1793,17 +1858,17 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() combinedResource := createAttributeValueResource("secret-engineering-usa-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN) // Test read access (all three allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // No other action is permitted by all three attributes for _, action := range []string{actions.ActionNameCreate, actions.ActionNameUpdate, actions.ActionNameDelete} { - d, err := pdp.GetDecision(s.T().Context(), entity, &policy.Action{Name: action}, []*authz.Resource{combinedResource}) + d, _, err := pdp.GetDecision(s.T().Context(), entity, &policy.Action{Name: action}, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(d) - s.False(d.Access, "Action %s should not be allowed", action) + s.False(d.AllPermitted, "Action %s should not be allowed", action) } }) @@ -1819,10 +1884,10 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() combinedResource := createAttributeValueResource("secret-engineering-usa-resource", testClassSecretFQN, testDeptEngineeringFQN, testCountryUSAFQN) // Test read access - should fail because department doesn't match - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) // Examine which attribute rule failed s.Len(decision.Results, 1) @@ -1863,16 +1928,16 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() ) // Test read access (all four allow) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{complexResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{complexResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // Test delete access (only platform:cloud allows) - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{complexResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, []*authz.Resource{complexResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // Overall fails because other attributes don't allow delete + s.False(decision.AllPermitted) // Overall fails because other attributes don't allow delete // Count how many attributes passed/failed for delete action s.Len(decision.Results, 1) @@ -1914,10 +1979,10 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Test read access - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") + s.True(decision.AllPermitted, "Entity with Secret clearance should have access to both Secret and Confidential") // Entity with confidential clearance (which should NOT give access to secret) entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ @@ -1925,10 +1990,10 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Test read access - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") + s.False(decision.AllPermitted, "Entity with Confidential clearance should NOT have access to both classifications") // Verify which rule failed s.Len(decision.Results, 1) @@ -1952,10 +2017,10 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Test read access - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access, "Entity with Secret clearance should have access to both Secret and Confidential") + s.True(decision.AllPermitted, "Entity with Secret clearance should have access to both Secret and Confidential") // Entity with confidential clearance (which should NOT give access to secret) entity = s.createEntityWithProps("confidential-entity", map[string]interface{}{ @@ -1963,10 +2028,10 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() }) // Test read access - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{cascadingResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access, "Entity with Confidential clearance should NOT have access to both classifications") + s.False(decision.AllPermitted, "Entity with Confidential clearance should NOT have access to both classifications") // Verify which rule failed s.Len(decision.Results, 1) @@ -2040,11 +2105,11 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { // Two resources with each a different namespaced attribute value resources := createResourcePerFqn(testClassSecretFQN, testProjectAlphaFQN) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 2) // Use FQN-based assertions @@ -2066,11 +2131,11 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { // Resource with attribute values from two different namespaces resource := createAttributeValueResource("secret-alpha-cloud-fqn", testClassSecretFQN, testProjectAlphaFQN, testPlatformCloudFQN) - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{resource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 1) onlyDecision := decision.Results[0] s.Len(onlyDecision.DataRuleResults, 3) @@ -2097,11 +2162,11 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { resources := createResourcePerFqn(testClassSecretFQN, testProjectAlphaFQN) // Create action is permitted for project alpha but not for secret - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionCreate, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionCreate, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 2) // Use FQN-based assertions @@ -2130,11 +2195,11 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { ) // Request for delete action - allowed only by platform cloud mapping - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 4) // Use FQN-based assertions @@ -2167,11 +2232,11 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { } // Request for read action - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) // The implementation treats this as a single resource with multiple rules s.Len(decision.Results, 1) @@ -2206,10 +2271,10 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { ) // Test read access - should pass for all namespaces - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, resources) s.Require().NoError(err) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 5) decisionResults := map[string]bool{ @@ -2222,11 +2287,11 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { s.assertAllDecisionResults(decision, decisionResults) // Test delete access - should only pass for hybrid platform - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionDelete, resources) // Overall access should be denied s.Require().NoError(err) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 5) // Only hybrid platform allows delete @@ -2258,10 +2323,10 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { ) // Test read access - should pass for this combined resource - decision, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) + decision, _, err := pdp.GetDecision(s.T().Context(), entity, testActionRead, []*authz.Resource{combinedResource}) s.Require().NoError(err) - s.True(decision.Access) + s.True(decision.AllPermitted) // The implementation treats this as a single resource with multiple rules s.Len(decision.Results, 1) @@ -2276,11 +2341,11 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { } // Test update access - should pass for all except country - decision, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) + decision, _, err = pdp.GetDecision(s.T().Context(), entity, testActionUpdate, []*authz.Resource{combinedResource}) // Overall access should be denied due to country not supporting update s.Require().NoError(err) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 1) onlyDecision = decision.Results[0] s.Equal("combined-multi-ns-resource", onlyDecision.ResourceID) @@ -2388,11 +2453,11 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { entityRegResFQN := createRegisteredResourceValueFQN(regResS3BucketEntity.GetName(), "ts-engineering") resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN, testNetworkPrivateFQN, testNetworkPublicFQN) - decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) + decision, _, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -2403,7 +2468,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { } s.assertAllDecisionResults(decision, expectedResults) for _, result := range decision.Results { - s.True(result.Passed, "All data rules should pass") + s.True(result.Entitled, "All data rules should pass") s.Len(result.DataRuleResults, 1) s.Empty(result.DataRuleResults[0].EntitlementFailures) } @@ -2416,11 +2481,11 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { resources := createResourcePerFqn(secretFQN, testDeptEngineeringFQN, networkPrivateFQN, testNetworkPublicFQN) - decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) + decision, _, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -2431,7 +2496,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { } s.assertAllDecisionResults(decision, expectedResults) for _, result := range decision.Results { - s.True(result.Passed, "All data rules should pass") + s.True(result.Entitled, "All data rules should pass") s.Len(result.DataRuleResults, 1) s.Empty(result.DataRuleResults[0].EntitlementFailures) } @@ -2442,11 +2507,11 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { resources := createResourcePerFqn(testClassSecretFQN, testDeptEngineeringFQN, testNetworkPrivateFQN, testNetworkPublicFQN) - decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionUpdate, resources) + decision, _, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionUpdate, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -2458,7 +2523,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { s.assertAllDecisionResults(decision, expectedResults) for idx, result := range decision.Results { - s.False(result.Passed, "Data rules should not pass") + s.False(result.Entitled, "Data rules should not pass") // Only expect rule results if the rule was evaluated, which doesn't happen for early // failures within action-attribute-value mismatches with the requested action if idx < 3 { @@ -2474,11 +2539,11 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { resources := createResourcePerFqn(testDeptEngineeringFQN, testClassSecretFQN, testNetworkPrivateFQN, testNetworkPublicFQN) // Get decision for delete action (not allowed by either attribute's subject mappings) - decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionDelete, resources) + decision, _, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionDelete, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -2495,11 +2560,11 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { resources := createResourcePerFqn(testClassSecretFQN, testDeptFinanceFQN, testNetworkPrivateFQN, testNetworkPublicFQN) - decision, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) + decision, _, err := pdp.GetDecisionRegisteredResource(s.T().Context(), entityRegResFQN, testActionRead, resources) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) // False because one resource is denied + s.False(decision.AllPermitted) // False because one resource is denied s.Len(decision.Results, 4) expectedResults := map[string]bool{ @@ -2515,10 +2580,10 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { s.Len(result.DataRuleResults, 1) if result.ResourceID == testClassSecretFQN { - s.True(result.Passed, "Secret should pass") + s.True(result.Entitled, "Secret should pass") s.Empty(result.DataRuleResults[0].EntitlementFailures) } else if result.ResourceID == testDeptFinanceFQN { - s.False(result.Passed, "Finance should not pass") + s.False(result.Entitled, "Finance should not pass") s.NotEmpty(result.DataRuleResults[0].EntitlementFailures) } } @@ -3087,7 +3152,7 @@ func (s *PDPTestSuite) Test_GetEntitlementsRegisteredResource() { func (s *PDPTestSuite) assertDecisionResult(decision *Decision, fqn string, shouldPass bool) { resourceDecision := findResourceDecision(decision, fqn) s.Require().NotNil(resourceDecision, "No result found for FQN: "+fqn) - s.Equal(shouldPass, resourceDecision.Passed, "Unexpected result for FQN %s. Expected (%t), got (%t)", fqn, shouldPass, resourceDecision.Passed) + s.Equal(shouldPass, resourceDecision.Entitled, "Unexpected result for FQN %s. Expected (%t), got (%t)", fqn, shouldPass, resourceDecision.Entitled) } // assertAllDecisionResults tests all FQNs in a map of FQN to expected pass/fail state diff --git a/service/logger/audit/getDecision.go b/service/logger/audit/getDecision.go index 36a29b4da..fb1c67d13 100644 --- a/service/logger/audit/getDecision.go +++ b/service/logger/audit/getDecision.go @@ -44,12 +44,14 @@ type GetDecisionEventParams struct { } type GetDecisionV2EventParams struct { - EntityID string - ActionName string - Decision DecisionResult - Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + EntityID string + ActionName string + Decision DecisionResult + Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions + FulfillableObligationValueFQNs []string + ObligationsSatisfied bool // Allow ResourceDecisions to be typed by the caller as structure is in-flight - ResourceDecisions interface{} + ResourceDecisions any } func CreateGetDecisionEvent(ctx context.Context, params GetDecisionEventParams) (*EventObject, error) { @@ -97,14 +99,26 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara result = ActionResultFailure } - actorAttributes := []interface{}{ + actorAttributes := []any{ struct { - Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions `json:"entitlements"` + Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions `json:"entitlements_relevant_to_decision"` }{ Entitlements: params.Entitlements, }, } + fulfillable := params.FulfillableObligationValueFQNs + if fulfillable == nil { + fulfillable = []string{} + } + + // Build event metadata with both resource decisions and obligations + eventMetadata := auditEventMetadata{ + "resource_decisions": params.ResourceDecisions, + "fulfillable_obligation_value_fqns": fulfillable, + "obligations_satisfied": params.ObligationsSatisfied, + } + return &EventObject{ Object: auditEventObject{ ID: params.EntityID + "-" + params.ActionName, @@ -119,7 +133,7 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara ID: params.EntityID, Attributes: actorAttributes, }, - EventMetaData: params.ResourceDecisions, + EventMetaData: eventMetadata, ClientInfo: eventClientInfo{ Platform: "authorization.v2", UserAgent: auditDataFromContext.UserAgent, @@ -130,8 +144,8 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara }, nil } -func buildActorAttributes(entityChainEntitlements []EntityChainEntitlement) []interface{} { - actorAttributes := make([]interface{}, len(entityChainEntitlements)) +func buildActorAttributes(entityChainEntitlements []EntityChainEntitlement) []any { + actorAttributes := make([]any, len(entityChainEntitlements)) for i, v := range entityChainEntitlements { actorAttributes[i] = struct { EntityID string `json:"entityId"` @@ -146,24 +160,18 @@ func buildActorAttributes(entityChainEntitlements []EntityChainEntitlement) []in return actorAttributes } -func buildEventMetadata(entityDecisions []EntityDecision) interface{} { - eventMetadata := struct { - Entities []interface{} `json:"entities"` - }{ - Entities: make([]interface{}, len(entityDecisions)), - } +func buildEventMetadata(entityDecisions []EntityDecision) auditEventMetadata { + entities := make([]map[string]any, len(entityDecisions)) for i, v := range entityDecisions { - eventMetadata.Entities[i] = struct { - EntityID string `json:"id"` - Decision string `json:"decision"` - Entitlements []string `json:"entitlements"` - }{ - EntityID: v.EntityID, - Decision: v.Decision, - Entitlements: v.Entitlements, + entities[i] = map[string]any{ + "id": v.EntityID, + "decision": v.Decision, + "entitlements": v.Entitlements, } } - return eventMetadata + return auditEventMetadata{ + "entities": entities, + } } diff --git a/service/logger/audit/getDecision_test.go b/service/logger/audit/getDecision_test.go index 9fcee5da3..c9a32b1a9 100644 --- a/service/logger/audit/getDecision_test.go +++ b/service/logger/audit/getDecision_test.go @@ -112,19 +112,30 @@ func TestBuildActorAttributes(t *testing.T) { } func TestBuildEventMetadata(t *testing.T) { - expectedMarshal := "{\"entities\":[{\"id\":\"test-entity-id\",\"decision\":\"permit\",\"entitlements\":[\"test-entitlement\"]},{\"id\":\"test-entity-id-2\",\"decision\":\"permit\",\"entitlements\":[\"test-entitlement-2\"]}]}" entityDecisions := []EntityDecision{ {EntityID: "test-entity-id", Decision: GetDecisionResultPermit.String(), Entitlements: []string{"test-entitlement"}}, {EntityID: "test-entity-id-2", Decision: GetDecisionResultPermit.String(), Entitlements: []string{"test-entitlement-2"}}, } actual := buildEventMetadata(entityDecisions) - actualMarshal, err := json.Marshal(actual) - if err != nil { - t.Fatalf("error marshalling event metadata: %v", err) + + // Verify the structure matches expected + expected := auditEventMetadata{ + "entities": []map[string]any{ + { + "id": "test-entity-id", + "decision": "permit", + "entitlements": []string{"test-entitlement"}, + }, + { + "id": "test-entity-id-2", + "decision": "permit", + "entitlements": []string{"test-entitlement-2"}, + }, + }, } - if string(actualMarshal) != expectedMarshal { - t.Fatalf("event metadata did not match expected: got %s, want %s", actualMarshal, expectedMarshal) + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("event metadata did not match expected: got %+v, want %+v", actual, expected) } } diff --git a/service/logger/audit/logger_test.go b/service/logger/audit/logger_test.go index f658cdf3f..e2164fec6 100644 --- a/service/logger/audit/logger_test.go +++ b/service/logger/audit/logger_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "reflect" "strings" "testing" "time" @@ -413,11 +414,19 @@ func TestGetDecision(t *testing.T) { logEntryTime.Format(time.RFC3339), ) - // Remove newlines and spaces from expected - expectedAuditLog = removeWhitespace(expectedAuditLog) - loggedMessage := removeWhitespace(string(logEntry.Audit)) + // Parse both JSON strings for structural comparison + var expected, actual map[string]any + if err := json.Unmarshal([]byte(expectedAuditLog), &expected); err != nil { + t.Fatalf("Failed to unmarshal expected JSON: %v", err) + } + if err := json.Unmarshal(logEntry.Audit, &actual); err != nil { + t.Fatalf("Failed to unmarshal actual JSON: %v", err) + } - if expectedAuditLog != loggedMessage { - t.Errorf("Expected audit log:\n%s\nGot:\n%s", expectedAuditLog, loggedMessage) + if !reflect.DeepEqual(expected, actual) { + // For better error messages, show the pretty-printed versions + expectedPretty, _ := json.MarshalIndent(expected, "", " ") + actualPretty, _ := json.MarshalIndent(actual, "", " ") + t.Errorf("Expected audit log:\n%s\nGot:\n%s", expectedPretty, actualPretty) } } diff --git a/service/logger/audit/rewrap.go b/service/logger/audit/rewrap.go index a07cdc32d..77dba335b 100644 --- a/service/logger/audit/rewrap.go +++ b/service/logger/audit/rewrap.go @@ -52,9 +52,9 @@ func CreateRewrapAuditEvent(ctx context.Context, params RewrapAuditEventParams) }, Actor: auditEventActor{ ID: auditDataFromContext.ActorID, - Attributes: make([]interface{}, 0), + Attributes: make([]any, 0), }, - EventMetaData: map[string]string{ + EventMetaData: auditEventMetadata{ "keyID": "", // TODO: keyID once implemented "policyBinding": params.PolicyBinding, "tdfFormat": params.TDFFormat, diff --git a/service/logger/audit/rewrap_test.go b/service/logger/audit/rewrap_test.go index 6fa36b482..5dae8c7a5 100644 --- a/service/logger/audit/rewrap_test.go +++ b/service/logger/audit/rewrap_test.go @@ -61,7 +61,7 @@ func TestCreateRewrapAuditEventHappyPath(t *testing.T) { t.Fatalf("event actor did not match expected: got %+v, want %+v", event.Actor, expectedEventActor) } - expectedEventMetaData := map[string]string{ + expectedEventMetaData := auditEventMetadata{ "keyID": "", "policyBinding": TestPolicyBinding, "tdfFormat": TestTDFFormat, diff --git a/service/logger/audit/utils.go b/service/logger/audit/utils.go index fc3880c15..b8cbfe711 100644 --- a/service/logger/audit/utils.go +++ b/service/logger/audit/utils.go @@ -14,18 +14,20 @@ const ( defaultNone = "None" ) +type auditEventMetadata map[string]any + // event type EventObject struct { - Object auditEventObject `json:"object"` - Action eventAction `json:"action"` - Actor auditEventActor `json:"actor"` - EventMetaData interface{} `json:"eventMetaData"` - ClientInfo eventClientInfo `json:"clientInfo"` + Object auditEventObject `json:"object"` + Action eventAction `json:"action"` + Actor auditEventActor `json:"actor"` + EventMetaData auditEventMetadata `json:"eventMetaData"` + ClientInfo eventClientInfo `json:"clientInfo"` - Original map[string]interface{} `json:"original,omitempty"` - Updated map[string]interface{} `json:"updated,omitempty"` - RequestID uuid.UUID `json:"requestId"` - Timestamp string `json:"timestamp"` + Original map[string]any `json:"original,omitempty"` + Updated map[string]any `json:"updated,omitempty"` + RequestID uuid.UUID `json:"requestId"` + Timestamp string `json:"timestamp"` } func (e EventObject) LogValue() slog.Value { @@ -85,8 +87,8 @@ func (e eventAction) LogValue() slog.Value { // event.actor type auditEventActor struct { - ID string `json:"id"` - Attributes []interface{} `json:"attributes"` + ID string `json:"id"` + Attributes []any `json:"attributes"` } func (e auditEventActor) LogValue() slog.Value {