From 5b5b234b86a9bdcddcdd499a5821422f9c4407fd Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Fri, 17 Oct 2025 14:09:42 -0700 Subject: [PATCH 01/19] feat(authz): audit logs should properly handle obligations --- .../internal/access/v2/just_in_time_pdp.go | 76 ++++++++++- service/internal/access/v2/pdp.go | 40 ++---- service/internal/access/v2/pdp_test.go | 126 +++++++++--------- service/logger/audit/getDecision.go | 26 +++- 4 files changed, 171 insertions(+), 97 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 6e019fb713..67238f380b 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 ( @@ -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) } @@ -175,6 +176,7 @@ func (p *JustInTimePDP) GetDecision( // If not entitled, obligations are not considered if !decision.Access { + p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, nil, nil, false) return []*Decision{decision}, decision.Access, nil } @@ -184,6 +186,10 @@ func (p *JustInTimePDP) GetDecision( decision.Results[idx].RequiredObligationValueFQNs = required } + // Flatten all required obligations across all resources + allRequiredObligationValueFQNs := flattenRequiredObligations(requiredObligationsPerResource) + + p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, allRequiredObligationValueFQNs, allTriggeredObligationsCanBeFulfilled) return []*Decision{decision}, decision.Access, nil default: @@ -195,9 +201,10 @@ 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) } @@ -208,10 +215,15 @@ func (p *JustInTimePDP) GetDecision( allPermitted = false } entityDecisions[idx] = d + entityEntitlements[idx] = entitlements } // If even one entity was denied access, obligations are not considered or returned if !allPermitted { + // Audit each entity decision + for idx, entityRep := range entityRepresentations { + p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], nil, nil, false) + } return entityDecisions, allPermitted, nil } @@ -224,6 +236,14 @@ func (p *JustInTimePDP) GetDecision( } } + // Flatten all required obligations across all resources + allRequiredObligationValueFQNs := flattenRequiredObligations(requiredObligationsPerResource) + + // Audit each entity decision with obligation information + for idx, entityRep := range entityRepresentations { + p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], fulfillableObligationValueFQNs, allRequiredObligationValueFQNs, allTriggeredObligationsCanBeFulfilled) + } + return entityDecisions, allPermitted, nil } @@ -400,3 +420,55 @@ 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, + requiredObligationValueFQNs []string, + obligationsSatisfied bool, +) { + // Determine audit decision result + auditDecision := audit.GetDecisionResultDeny + if decision.Access { + auditDecision = audit.GetDecisionResultPermit + } + + // Ensure entitlements is not nil + if entitlements == nil { + entitlements = make(map[string][]*policy.Action) + } + + p.logger.Audit.GetDecisionV2(ctx, audit.GetDecisionV2EventParams{ + EntityID: entityID, + ActionName: action.GetName(), + Decision: auditDecision, + Entitlements: entitlements, + FulfillableObligationValueFQNs: fulfillableObligationValueFQNs, + RequiredObligationValueFQNs: requiredObligationValueFQNs, + ObligationsSatisfied: obligationsSatisfied, + ResourceDecisions: decision.Results, + }) +} + +// flattenRequiredObligations takes a per-resource list of required obligation FQNs +// and returns a deduplicated flat list of all required obligations across all resources +func flattenRequiredObligations(requiredObligationsPerResource [][]string) []string { + seen := make(map[string]struct{}) + var result []string + + for _, resourceObligations := range requiredObligationsPerResource { + for _, oblFQN := range resourceObligations { + if _, exists := seen[oblFQN]; !exists { + seen[oblFQN] = struct{}{} + result = append(result, oblFQN) + } + } + } + + return result +} diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 1406bd4319..f669f643b3 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -15,7 +15,6 @@ 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. @@ -162,32 +161,32 @@ 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)) @@ -199,7 +198,7 @@ func (p *PolicyDecisionPoint) GetDecision( 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 { @@ -216,20 +215,7 @@ func (p *PolicyDecisionPoint) GetDecision( 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 +223,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))) @@ -290,7 +276,7 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( 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 @@ -306,7 +292,7 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( 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 80ca8ce1ce..708af58eb0 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -892,7 +892,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { testClassSecretRegResFQN, 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) @@ -923,7 +923,7 @@ 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) @@ -955,7 +955,7 @@ 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) @@ -992,7 +992,7 @@ 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) @@ -1019,7 +1019,7 @@ 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) @@ -1070,7 +1070,7 @@ 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) @@ -1118,7 +1118,7 @@ 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) @@ -1165,7 +1165,7 @@ 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) @@ -1213,7 +1213,7 @@ 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) @@ -1259,7 +1259,7 @@ 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) @@ -1306,7 +1306,7 @@ 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) @@ -1419,7 +1419,7 @@ 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) @@ -1428,7 +1428,7 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { 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 @@ -1447,32 +1447,32 @@ 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.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 // 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 // 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 // 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) @@ -1487,31 +1487,31 @@ 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) // 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) // 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) // 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) // 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) @@ -1563,19 +1563,19 @@ 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) // 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) // 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) @@ -1598,19 +1598,19 @@ 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) // 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) // 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) @@ -1633,19 +1633,19 @@ 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) // 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) // 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) @@ -1682,19 +1682,19 @@ 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) // 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 // 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 @@ -1711,13 +1711,13 @@ 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) // 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 @@ -1734,13 +1734,13 @@ 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) // 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 @@ -1758,7 +1758,7 @@ 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) @@ -1793,14 +1793,14 @@ 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) // 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) @@ -1819,7 +1819,7 @@ 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) @@ -1863,13 +1863,13 @@ 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) // 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 @@ -1914,7 +1914,7 @@ 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") @@ -1925,7 +1925,7 @@ 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") @@ -1952,7 +1952,7 @@ 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") @@ -1963,7 +1963,7 @@ 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") @@ -2040,7 +2040,7 @@ 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) @@ -2066,7 +2066,7 @@ 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) @@ -2097,7 +2097,7 @@ 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) @@ -2130,7 +2130,7 @@ 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) @@ -2167,7 +2167,7 @@ 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) @@ -2206,7 +2206,7 @@ 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) @@ -2222,7 +2222,7 @@ 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) @@ -2258,7 +2258,7 @@ 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) @@ -2276,7 +2276,7 @@ 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) @@ -2388,7 +2388,7 @@ 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) @@ -2416,7 +2416,7 @@ 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) @@ -2442,7 +2442,7 @@ 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) @@ -2474,7 +2474,7 @@ 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) @@ -2495,7 +2495,7 @@ 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) diff --git a/service/logger/audit/getDecision.go b/service/logger/audit/getDecision.go index 36a29b4dab..c99d3a3cee 100644 --- a/service/logger/audit/getDecision.go +++ b/service/logger/audit/getDecision.go @@ -44,10 +44,13 @@ 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 + RequiredObligationValueFQNs []string + ObligationsSatisfied bool // Allow ResourceDecisions to be typed by the caller as structure is in-flight ResourceDecisions interface{} } @@ -105,6 +108,19 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara }, } + // Build event metadata with both resource decisions and obligations + eventMetadata := struct { + ResourceDecisions interface{} `json:"resource_decisions"` + FulfillableObligationValueFQNs []string `json:"fulfillable_obligation_value_fqns,omitempty"` + RequiredObligationValueFQNs []string `json:"required_obligation_value_fqns,omitempty"` + ObligationsSatisfied bool `json:"obligations_satisfied"` + }{ + ResourceDecisions: params.ResourceDecisions, + FulfillableObligationValueFQNs: params.FulfillableObligationValueFQNs, + RequiredObligationValueFQNs: params.RequiredObligationValueFQNs, + ObligationsSatisfied: params.ObligationsSatisfied, + } + return &EventObject{ Object: auditEventObject{ ID: params.EntityID + "-" + params.ActionName, @@ -119,7 +135,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, From 4ac327fa9f1c9e3cd1138aea129845e354853437 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 13:09:41 -0700 Subject: [PATCH 02/19] improve obligation decision result and audit logic --- .../internal/access/v2/just_in_time_pdp.go | 49 +++++-------------- .../access/v2/obligations/obligations_pdp.go | 23 ++++++--- .../v2/obligations/obligations_pdp_test.go | 6 +-- 3 files changed, 32 insertions(+), 46 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 67238f380b..884c29d5a5 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -142,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, @@ -176,20 +176,17 @@ func (p *JustInTimePDP) GetDecision( // If not entitled, obligations are not considered if !decision.Access { - p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, nil, nil, false) + p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, nil, obligationDecision) 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.Access = obligationDecision.AllObligationsAreFulfilled + for idx, required := range obligationDecision.RequiredObligationValueFQNsPerResource { decision.Results[idx].RequiredObligationValueFQNs = required } - // Flatten all required obligations across all resources - allRequiredObligationValueFQNs := flattenRequiredObligations(requiredObligationsPerResource) - - p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, allRequiredObligationValueFQNs, allTriggeredObligationsCanBeFulfilled) + p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) return []*Decision{decision}, decision.Access, nil default: @@ -222,26 +219,23 @@ func (p *JustInTimePDP) GetDecision( if !allPermitted { // Audit each entity decision for idx, entityRep := range entityRepresentations { - p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], nil, nil, false) + p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], nil, obligationDecision) } return entityDecisions, allPermitted, nil } // Access should only be granted if entitled AND obligations fulfilled - allPermitted = allTriggeredObligationsCanBeFulfilled + allPermitted = obligationDecision.AllObligationsAreFulfilled // 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 { + for idx, required := range obligationDecision.RequiredObligationValueFQNsPerResource { decision.Results[idx].RequiredObligationValueFQNs = required } } - // Flatten all required obligations across all resources - allRequiredObligationValueFQNs := flattenRequiredObligations(requiredObligationsPerResource) - // Audit each entity decision with obligation information for idx, entityRep := range entityRepresentations { - p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], fulfillableObligationValueFQNs, allRequiredObligationValueFQNs, allTriggeredObligationsCanBeFulfilled) + p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], fulfillableObligationValueFQNs, obligationDecision) } return entityDecisions, allPermitted, nil @@ -429,8 +423,7 @@ func (p *JustInTimePDP) auditDecision( decision *Decision, entitlements map[string][]*policy.Action, fulfillableObligationValueFQNs []string, - requiredObligationValueFQNs []string, - obligationsSatisfied bool, + obligationDecision obligations.ObligationPolicyDecision, ) { // Determine audit decision result auditDecision := audit.GetDecisionResultDeny @@ -449,26 +442,8 @@ func (p *JustInTimePDP) auditDecision( Decision: auditDecision, Entitlements: entitlements, FulfillableObligationValueFQNs: fulfillableObligationValueFQNs, - RequiredObligationValueFQNs: requiredObligationValueFQNs, - ObligationsSatisfied: obligationsSatisfied, + RequiredObligationValueFQNs: obligationDecision.RequiredObligationValueFQNs, + ObligationsSatisfied: obligationDecision.AllObligationsAreFulfilled, ResourceDecisions: decision.Results, }) } - -// flattenRequiredObligations takes a per-resource list of required obligation FQNs -// and returns a deduplicated flat list of all required obligations across all resources -func flattenRequiredObligations(requiredObligationsPerResource [][]string) []string { - seen := make(map[string]struct{}) - var result []string - - for _, resourceObligations := range requiredObligationsPerResource { - for _, oblFQN := range resourceObligations { - if _, exists := seen[oblFQN]; !exists { - seen[oblFQN] = struct{}{} - result = append(result, oblFQN) - } - } - } - - return result -} diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go index 2acb23c979..f5eee08a97 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -43,6 +43,15 @@ type ObligationsPolicyDecisionPoint struct { clientIDScopedTriggerActionsToAttributes map[string]obligationValuesByActionOnAnAttributeValue } +type ObligationPolicyDecision struct { + // Whether or not all the obligations that were triggered can be fulfilled by the caller + AllObligationsAreFulfilled bool + // The Set of obligations required across all resources in the decision + RequiredObligationValueFQNs []string + // The Set of obligations required on each indexed resource + RequiredObligationValueFQNsPerResource [][]string +} + func NewObligationsPolicyDecisionPoint( ctx context.Context, l *logger.Logger, @@ -120,23 +129,25 @@ 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) { +) (ObligationPolicyDecision, error) { perResource, 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 + return ObligationPolicyDecision{ + AllObligationsAreFulfilled: allFulfilled, + RequiredObligationValueFQNs: allTriggered, + RequiredObligationValueFQNsPerResource: perResource, + }, nil } // getAllObligationsAreFulfilled checks the deduplicated list of triggered obligations against the PEP diff --git a/service/internal/access/v2/obligations/obligations_pdp_test.go b/service/internal/access/v2/obligations/obligations_pdp_test.go index d8111677b0..68ec4088a7 100644 --- a/service/internal/access/v2/obligations/obligations_pdp_test.go +++ b/service/internal/access/v2/obligations/obligations_pdp_test.go @@ -1017,10 +1017,10 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( } 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.AllObligationsAreFulfilled, tt.name) + s.Equal(tt.wantPerResource, decision.RequiredObligationValueFQNsPerResource, tt.name) }) } } From 98753949ba5b95711d4725bd2d4429db812588eb Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 13:43:49 -0700 Subject: [PATCH 03/19] cleanup --- service/internal/access/v2/just_in_time_pdp.go | 13 +++---------- service/logger/audit/getDecision.go | 4 +--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 884c29d5a5..68a2e958ec 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -176,7 +176,7 @@ func (p *JustInTimePDP) GetDecision( // If not entitled, obligations are not considered if !decision.Access { - p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, nil, obligationDecision) + p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) return []*Decision{decision}, decision.Access, nil } @@ -219,7 +219,7 @@ func (p *JustInTimePDP) GetDecision( if !allPermitted { // Audit each entity decision for idx, entityRep := range entityRepresentations { - p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], nil, obligationDecision) + p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], fulfillableObligationValueFQNs, obligationDecision) } return entityDecisions, allPermitted, nil } @@ -301,8 +301,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) @@ -427,15 +425,10 @@ func (p *JustInTimePDP) auditDecision( ) { // Determine audit decision result auditDecision := audit.GetDecisionResultDeny - if decision.Access { + if decision.Access && obligationDecision.AllObligationsAreFulfilled { auditDecision = audit.GetDecisionResultPermit } - // Ensure entitlements is not nil - if entitlements == nil { - entitlements = make(map[string][]*policy.Action) - } - p.logger.Audit.GetDecisionV2(ctx, audit.GetDecisionV2EventParams{ EntityID: entityID, ActionName: action.GetName(), diff --git a/service/logger/audit/getDecision.go b/service/logger/audit/getDecision.go index c99d3a3cee..e28f45ef40 100644 --- a/service/logger/audit/getDecision.go +++ b/service/logger/audit/getDecision.go @@ -102,7 +102,7 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara actorAttributes := []interface{}{ struct { - Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions `json:"entitlements"` + Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions `json:"entitlements_relevant_to_decision"` }{ Entitlements: params.Entitlements, }, @@ -112,12 +112,10 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara eventMetadata := struct { ResourceDecisions interface{} `json:"resource_decisions"` FulfillableObligationValueFQNs []string `json:"fulfillable_obligation_value_fqns,omitempty"` - RequiredObligationValueFQNs []string `json:"required_obligation_value_fqns,omitempty"` ObligationsSatisfied bool `json:"obligations_satisfied"` }{ ResourceDecisions: params.ResourceDecisions, FulfillableObligationValueFQNs: params.FulfillableObligationValueFQNs, - RequiredObligationValueFQNs: params.RequiredObligationValueFQNs, ObligationsSatisfied: params.ObligationsSatisfied, } From 12d153d843ec3af0c8242df691b75e81c4bcacd0 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 14:19:03 -0700 Subject: [PATCH 04/19] isolated unit test that entitlements come back for audit --- service/internal/access/v2/pdp_test.go | 67 +++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 708af58eb0..85a9890421 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -892,7 +892,7 @@ 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) @@ -911,6 +911,16 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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() { @@ -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.Access, "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 From 061b9fb29cb37d6d1b2b6c51e77f5270e3d1b5a0 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 14:39:16 -0700 Subject: [PATCH 05/19] gemini suggestion unused param --- service/internal/access/v2/just_in_time_pdp.go | 1 - service/logger/audit/getDecision.go | 1 - 2 files changed, 2 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 68a2e958ec..752fc83beb 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -435,7 +435,6 @@ func (p *JustInTimePDP) auditDecision( Decision: auditDecision, Entitlements: entitlements, FulfillableObligationValueFQNs: fulfillableObligationValueFQNs, - RequiredObligationValueFQNs: obligationDecision.RequiredObligationValueFQNs, ObligationsSatisfied: obligationDecision.AllObligationsAreFulfilled, ResourceDecisions: decision.Results, }) diff --git a/service/logger/audit/getDecision.go b/service/logger/audit/getDecision.go index e28f45ef40..d07c451e88 100644 --- a/service/logger/audit/getDecision.go +++ b/service/logger/audit/getDecision.go @@ -49,7 +49,6 @@ type GetDecisionV2EventParams struct { Decision DecisionResult Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions FulfillableObligationValueFQNs []string - RequiredObligationValueFQNs []string ObligationsSatisfied bool // Allow ResourceDecisions to be typed by the caller as structure is in-flight ResourceDecisions interface{} From c46dec263af52001b49824e91183bf44f869cf8e Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 14:39:22 -0700 Subject: [PATCH 06/19] improve tests --- .../v2/obligations/obligations_pdp_test.go | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/service/internal/access/v2/obligations/obligations_pdp_test.go b/service/internal/access/v2/obligations/obligations_pdp_test.go index 68ec4088a7..3b9f89f116 100644 --- a/service/internal/access/v2/obligations/obligations_pdp_test.go +++ b/service/internal/access/v2/obligations/obligations_pdp_test.go @@ -887,6 +887,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( args args wantAllFulfilled bool wantPerResource [][]string + wantOverall []string }{ { name: "fulfilled - attributes", @@ -900,11 +901,24 @@ 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: [][]string{{mockObligationFQN1},{mockObligationFQN2}}, + wantOverall: []string{mockObligationFQN1, mockObligationFQN2}, }, { name: "fulfilled - registered resource", @@ -921,6 +935,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( }, wantAllFulfilled: true, wantPerResource: [][]string{{mockObligationFQN1}}, + wantOverall: []string{mockObligationFQN1}, }, { name: "fulfilled - registered resource client scoped", @@ -942,6 +957,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( }, wantAllFulfilled: true, wantPerResource: [][]string{{mockObligationFQN2}}, + wantOverall: []string{mockObligationFQN2}, }, { name: "fulfilled - casing mismatches", @@ -962,6 +978,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( }, wantAllFulfilled: true, wantPerResource: [][]string{{mockObligationFQN1}}, + wantOverall: []string{mockObligationFQN1}, }, { name: "unfulfilled - attributes", @@ -980,6 +997,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( }, wantAllFulfilled: false, wantPerResource: [][]string{{mockObligationFQN1}}, + wantOverall: []string{mockObligationFQN1}, }, { name: "unfulfilled - registered resource", @@ -996,6 +1014,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( }, wantAllFulfilled: false, wantPerResource: [][]string{{mockObligationFQN1}}, + wantOverall: []string{mockObligationFQN1}, }, { name: "no obligations triggered", @@ -1021,6 +1040,7 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( s.Require().NoError(err) s.Equal(tt.wantAllFulfilled, decision.AllObligationsAreFulfilled, tt.name) s.Equal(tt.wantPerResource, decision.RequiredObligationValueFQNsPerResource, tt.name) + s.Equal(tt.wantOverall, decision.RequiredObligationValueFQNs, tt.name) }) } } From 46d1c4405fdc224dca7b65014de26ba10051f902 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 14:53:04 -0700 Subject: [PATCH 07/19] audit log should have empty fulfillable obligations and not nil when not provided --- service/logger/audit/getDecision.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/service/logger/audit/getDecision.go b/service/logger/audit/getDecision.go index d07c451e88..11b3541130 100644 --- a/service/logger/audit/getDecision.go +++ b/service/logger/audit/getDecision.go @@ -107,14 +107,19 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara }, } + fulfillable := params.FulfillableObligationValueFQNs + if fulfillable == nil { + fulfillable = []string{} + } + // Build event metadata with both resource decisions and obligations eventMetadata := struct { ResourceDecisions interface{} `json:"resource_decisions"` - FulfillableObligationValueFQNs []string `json:"fulfillable_obligation_value_fqns,omitempty"` + FulfillableObligationValueFQNs []string `json:"fulfillable_obligation_value_fqns"` ObligationsSatisfied bool `json:"obligations_satisfied"` }{ ResourceDecisions: params.ResourceDecisions, - FulfillableObligationValueFQNs: params.FulfillableObligationValueFQNs, + FulfillableObligationValueFQNs: fulfillable, ObligationsSatisfied: params.ObligationsSatisfied, } From ea87577626ba0855a0ed2677440851f4f775c2d3 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 16:34:48 -0700 Subject: [PATCH 08/19] improved logs and resource-level decisioning in response --- service/internal/access/v2/evaluate.go | 4 +- service/internal/access/v2/evaluate_test.go | 4 +- .../internal/access/v2/just_in_time_pdp.go | 52 +-- .../access/v2/obligations/obligations_pdp.go | 59 +++- .../v2/obligations/obligations_pdp_test.go | 325 ++++++++++++++---- service/internal/access/v2/pdp.go | 14 +- service/internal/access/v2/pdp_test.go | 32 +- 7 files changed, 350 insertions(+), 140 deletions(-) diff --git a/service/internal/access/v2/evaluate.go b/service/internal/access/v2/evaluate.go index 50067158b8..ec9109dac7 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 468f225aa9..d95e45160f 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 752fc83beb..bd735cde3b 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -174,16 +174,20 @@ 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 not entitled, obligations are not populated on the returned results if !decision.Access { p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) return []*Decision{decision}, decision.Access, nil } // Access should only be granted if entitled AND obligations fulfilled - decision.Access = obligationDecision.AllObligationsAreFulfilled - for idx, required := range obligationDecision.RequiredObligationValueFQNsPerResource { - decision.Results[idx].RequiredObligationValueFQNs = required + decision.Access = obligationDecision.AllObligationsSatisfied + for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { + resourceDecision := decision.Results[idx] + resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied + resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs + resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + decision.Results[idx] = resourceDecision } p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) @@ -215,27 +219,27 @@ func (p *JustInTimePDP) GetDecision( entityEntitlements[idx] = entitlements } - // If even one entity was denied access, obligations are not considered or returned - if !allPermitted { - // Audit each entity decision + // Return obligations if entitled, whether or not they were fulfilled + if allPermitted { + // But only grant access if entitled AND obligations fulfilled + allPermitted = obligationDecision.AllObligationsSatisfied + + // Propagate required obligations within policy on each resource decision object + for idx, decision := range entityDecisions { + for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { + resourceDecision := decision.Results[idx] + resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied + resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs + resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + decision.Results[idx] = resourceDecision + } + entityRep := entityRepresentations[idx] + p.auditDecision(ctx, entityRep.GetOriginalId(), action, decision, entityEntitlements[idx], fulfillableObligationValueFQNs, obligationDecision) + } + } else { for idx, entityRep := range entityRepresentations { p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], fulfillableObligationValueFQNs, obligationDecision) } - return entityDecisions, allPermitted, nil - } - - // Access should only be granted if entitled AND obligations fulfilled - allPermitted = obligationDecision.AllObligationsAreFulfilled - // Obligations are not entity-specific at this time so will be the same across every entity - for _, decision := range entityDecisions { - for idx, required := range obligationDecision.RequiredObligationValueFQNsPerResource { - decision.Results[idx].RequiredObligationValueFQNs = required - } - } - - // Audit each entity decision with obligation information - for idx, entityRep := range entityRepresentations { - p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], fulfillableObligationValueFQNs, obligationDecision) } return entityDecisions, allPermitted, nil @@ -425,7 +429,7 @@ func (p *JustInTimePDP) auditDecision( ) { // Determine audit decision result auditDecision := audit.GetDecisionResultDeny - if decision.Access && obligationDecision.AllObligationsAreFulfilled { + if decision.Access && obligationDecision.AllObligationsSatisfied { auditDecision = audit.GetDecisionResultPermit } @@ -435,7 +439,7 @@ func (p *JustInTimePDP) auditDecision( Decision: auditDecision, Entitlements: entitlements, FulfillableObligationValueFQNs: fulfillableObligationValueFQNs, - ObligationsSatisfied: obligationDecision.AllObligationsAreFulfilled, + 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 f5eee08a97..f8ded822aa 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -43,13 +43,20 @@ 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 - AllObligationsAreFulfilled bool + 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 [][]string + RequiredObligationValueFQNsPerResource []PerResourceDecision } func NewObligationsPolicyDecisionPoint( @@ -137,32 +144,32 @@ func (p *ObligationsPolicyDecisionPoint) GetAllTriggeredObligationsAreFulfilled( decisionRequestContext *policy.RequestContext, pepFulfillableObligationValueFQNs []string, ) (ObligationPolicyDecision, error) { - perResource, allTriggered, err := p.getTriggeredObligations(ctx, action, resources, decisionRequestContext) + perResourceTriggered, allTriggered, err := p.getTriggeredObligations(ctx, action, resources, decisionRequestContext) if err != nil { return ObligationPolicyDecision{}, err } - allFulfilled := p.getAllObligationsAreFulfilled(ctx, action, allTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext) + perResourceDecisions, allFulfilled := p.rollupResourceObligationDecisions(ctx, action, perResourceTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext) return ObligationPolicyDecision{ - AllObligationsAreFulfilled: allFulfilled, + AllObligationsSatisfied: allFulfilled, RequiredObligationValueFQNs: allTriggered, - RequiredObligationValueFQNsPerResource: perResource, + 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{}) @@ -171,29 +178,43 @@ func (p *ObligationsPolicyDecisionPoint) getAllObligationsAreFulfilled( fulfillable[obligation] = struct{}{} } + seen := 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 { + allSatisfied := true + for _, triggered := range resourceTriggeredObligations { + triggered = strings.ToLower(triggered) + if _, ok := fulfillable[triggered]; !ok { + if _, seen := seen[triggered]; seen { + continue + } + seen[triggered] = struct{}{} + unfulfilled = append(unfulfilled, triggered) + allSatisfied = false + } + } + results[i] = PerResourceDecision{ + ObligationsSatisfied: allSatisfied, + 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", + "all triggered obligations reported as fulfillable", ) - return true + return results, true } // getTriggeredObligations takes in an action and multiple resources subject to decisioning. diff --git a/service/internal/access/v2/obligations/obligations_pdp_test.go b/service/internal/access/v2/obligations/obligations_pdp_test.go index 3b9f89f116..658753c037 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} + + 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.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} - 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.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} - 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, 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} - 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.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, + }, + } - 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, + decisionRequestContext, + ) + + s.True(allFulfilled) + s.Len(perResource, 2) + s.True(perResource[0].ObligationsSatisfied) + s.True(perResource[1].ObligationsSatisfied) } func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke() { @@ -886,8 +1028,8 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( name string args args wantAllFulfilled bool - wantPerResource [][]string - wantOverall []string + wantPerResource []PerResourceDecision + wantOverall []string }{ { name: "fulfilled - attributes", @@ -917,7 +1059,16 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN1, mockObligationFQN2, mockObligationFQN3}, }, wantAllFulfilled: true, - wantPerResource: [][]string{{mockObligationFQN1},{mockObligationFQN2}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN2}, + }, + }, wantOverall: []string{mockObligationFQN1, mockObligationFQN2}, }, { @@ -934,7 +1085,12 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN1}, }, wantAllFulfilled: true, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + }, wantOverall: []string{mockObligationFQN1}, }, { @@ -956,7 +1112,12 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN2}, }, wantAllFulfilled: true, - wantPerResource: [][]string{{mockObligationFQN2}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: true, + RequiredObligationValueFQNs: []string{mockObligationFQN2}, + }, + }, wantOverall: []string{mockObligationFQN2}, }, { @@ -977,7 +1138,12 @@ 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}, }, { @@ -996,7 +1162,12 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN2}, }, wantAllFulfilled: false, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: false, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + }, wantOverall: []string{mockObligationFQN1}, }, { @@ -1013,7 +1184,12 @@ func (s *ObligationsPDPSuite) Test_GetAllTriggeredObligationsAreFulfilled_Smoke( pepFulfillable: []string{mockObligationFQN2}, }, wantAllFulfilled: false, - wantPerResource: [][]string{{mockObligationFQN1}}, + wantPerResource: []PerResourceDecision{ + { + ObligationsSatisfied: false, + RequiredObligationValueFQNs: []string{mockObligationFQN1}, + }, + }, wantOverall: []string{mockObligationFQN1}, }, { @@ -1031,14 +1207,19 @@ 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) { decision, err := s.pdp.GetAllTriggeredObligationsAreFulfilled(t.Context(), tt.args.resources, tt.args.action, tt.args.decisionRequestContext, tt.args.pepFulfillable) s.Require().NoError(err) - s.Equal(tt.wantAllFulfilled, decision.AllObligationsAreFulfilled, 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 f669f643b3..e4a3c97bf2 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -25,7 +25,11 @@ type Decision struct { // 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"` @@ -201,14 +205,14 @@ func (p *PolicyDecisionPoint) GetDecision( return nil, nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } - if !resourceDecision.Passed { + if !resourceDecision.Entitled { decision.Access = false } l.DebugContext( ctx, "resourceDecision result", - slog.Bool("passed", resourceDecision.Passed), + slog.Bool("passed", resourceDecision.Entitled), slog.String("resource_id", resourceDecision.ResourceID), slog.Int("data_rule_results_count", len(resourceDecision.DataRuleResults)), ) @@ -278,14 +282,14 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( if err != nil || resourceDecision == nil { return nil, nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } - if !resourceDecision.Passed { + if !resourceDecision.Entitled { decision.Access = 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)), ) diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index 85a9890421..a43bb36bfd 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -907,7 +907,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) } @@ -948,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) } @@ -981,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 { @@ -1049,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) } } @@ -1089,7 +1089,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 { @@ -1137,7 +1137,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 { @@ -1184,7 +1184,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 { @@ -1232,7 +1232,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 @@ -1831,7 +1831,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() // 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 { @@ -2468,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) } @@ -2496,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) } @@ -2523,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 { @@ -2580,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) } } @@ -3152,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 From 3088d71780d31daa2c55bea4ff0458c03a64a6e7 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 16:38:57 -0700 Subject: [PATCH 09/19] cleanup --- .../internal/access/v2/just_in_time_pdp.go | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index bd735cde3b..286ad8e736 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -174,20 +174,18 @@ 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 populated on the returned results - if !decision.Access { - p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) - return []*Decision{decision}, decision.Access, nil - } - - // Access should only be granted if entitled AND obligations fulfilled - decision.Access = obligationDecision.AllObligationsSatisfied - for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { - resourceDecision := decision.Results[idx] - resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied - resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs - resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied - decision.Results[idx] = resourceDecision + // Obligations are only populated on the returned results if entitled + if decision.Access { + // Access should only be granted if entitled AND obligations fulfilled + decision.Access = obligationDecision.AllObligationsSatisfied + + for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { + resourceDecision := decision.Results[idx] + resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied + resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs + resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + decision.Results[idx] = resourceDecision + } } p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) From dca85469eff5d7737d799c7ed37f4304201830ed Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 19:34:54 -0700 Subject: [PATCH 10/19] fix multiresource obligations decisioning --- .../internal/access/v2/obligations/obligations_pdp.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go index f8ded822aa..8602f12a6f 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -178,7 +178,7 @@ func (p *ObligationsPolicyDecisionPoint) rollupResourceObligationDecisions( fulfillable[obligation] = struct{}{} } - seen := make(map[string]struct{}) + unfulfilledSeen := make(map[string]struct{}) var unfulfilled []string results := make([]PerResourceDecision, len(perResourceTriggeredObligationValueFQNs)) for i, resourceTriggeredObligations := range perResourceTriggeredObligationValueFQNs { @@ -186,11 +186,10 @@ func (p *ObligationsPolicyDecisionPoint) rollupResourceObligationDecisions( for _, triggered := range resourceTriggeredObligations { triggered = strings.ToLower(triggered) if _, ok := fulfillable[triggered]; !ok { - if _, seen := seen[triggered]; seen { - continue + if _, seen := unfulfilledSeen[triggered]; !seen { + unfulfilledSeen[triggered] = struct{}{} + unfulfilled = append(unfulfilled, triggered) } - seen[triggered] = struct{}{} - unfulfilled = append(unfulfilled, triggered) allSatisfied = false } } From e814ddf5e342ebedca476f02f5075fd632f15057 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 19:35:57 -0700 Subject: [PATCH 11/19] cleanup --- service/internal/access/v2/just_in_time_pdp.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 286ad8e736..18b5e5d4c8 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -178,7 +178,7 @@ func (p *JustInTimePDP) GetDecision( if decision.Access { // Access should only be granted if entitled AND obligations fulfilled decision.Access = obligationDecision.AllObligationsSatisfied - + for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { resourceDecision := decision.Results[idx] resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied @@ -223,7 +223,7 @@ func (p *JustInTimePDP) GetDecision( allPermitted = obligationDecision.AllObligationsSatisfied // Propagate required obligations within policy on each resource decision object - for idx, decision := range entityDecisions { + for entityIdx, decision := range entityDecisions { for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { resourceDecision := decision.Results[idx] resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied @@ -231,13 +231,15 @@ func (p *JustInTimePDP) GetDecision( resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied decision.Results[idx] = resourceDecision } - entityRep := entityRepresentations[idx] - p.auditDecision(ctx, entityRep.GetOriginalId(), action, decision, entityEntitlements[idx], fulfillableObligationValueFQNs, obligationDecision) - } - } else { - for idx, entityRep := range entityRepresentations { - p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[idx], entityEntitlements[idx], fulfillableObligationValueFQNs, obligationDecision) + entityRepID := entityRepresentations[entityIdx].GetOriginalId() + p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) } + return entityDecisions, allPermitted, nil + } + + // Not entitled, so just log decisions to audit without setting obligations to the result + for entityIdx, entityRep := range entityRepresentations { + p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[entityIdx], entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) } return entityDecisions, allPermitted, nil From e92f6f78508198090848aaca2363eeafa9830800 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 19:45:22 -0700 Subject: [PATCH 12/19] cleanup --- service/internal/access/v2/just_in_time_pdp.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 18b5e5d4c8..4448a995c1 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -181,9 +181,11 @@ func (p *JustInTimePDP) GetDecision( for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { resourceDecision := decision.Results[idx] + resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + decision.Results[idx] = resourceDecision } } @@ -226,11 +228,14 @@ func (p *JustInTimePDP) GetDecision( for entityIdx, decision := range entityDecisions { for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { resourceDecision := decision.Results[idx] + resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + decision.Results[idx] = resourceDecision } + decision.Access = allPermitted entityRepID := entityRepresentations[entityIdx].GetOriginalId() p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) } @@ -429,7 +434,7 @@ func (p *JustInTimePDP) auditDecision( ) { // Determine audit decision result auditDecision := audit.GetDecisionResultDeny - if decision.Access && obligationDecision.AllObligationsSatisfied { + if decision.Access { auditDecision = audit.GetDecisionResultPermit } From 967dcb323517d58ccca847705533f2253a120974 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 20:28:06 -0700 Subject: [PATCH 13/19] working --- .../internal/access/v2/just_in_time_pdp.go | 62 +++++++++++-------- .../access/v2/obligations/obligations_pdp.go | 4 +- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 4448a995c1..e5813bf4de 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -174,20 +174,29 @@ func (p *JustInTimePDP) GetDecision( return nil, false, fmt.Errorf("decision is nil for registered resource value FQN [%s]", regResValueFQN) } - // Obligations are only populated on the returned results if entitled - if decision.Access { + // Update resource decisions with obligations and set final access decision + hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 + if decision.Access && hasRequiredObligations { // Access should only be granted if entitled AND obligations fulfilled decision.Access = obligationDecision.AllObligationsSatisfied + } - for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { - resourceDecision := decision.Results[idx] + // Propagate obligations within policy on each resource decision object + 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 - resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied - - decision.Results[idx] = resourceDecision + } else { + // No required obligations means all obligations are satisfied + resourceDecision.ObligationsSatisfied = true } + + resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + decision.Results[idx] = resourceDecision } p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) @@ -219,32 +228,35 @@ func (p *JustInTimePDP) GetDecision( entityEntitlements[idx] = entitlements } - // Return obligations if entitled, whether or not they were fulfilled - if allPermitted { - // But only grant access if entitled AND obligations fulfilled + // Update resource decisions with obligations and set final access decision + hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 + if allPermitted && hasRequiredObligations { + // Only grant access if entitled AND obligations fulfilled allPermitted = obligationDecision.AllObligationsSatisfied + } - // Propagate required obligations within policy on each resource decision object - for entityIdx, decision := range entityDecisions { - for idx, perResource := range obligationDecision.RequiredObligationValueFQNsPerResource { - resourceDecision := decision.Results[idx] + // Propagate obligations within policy on each resource decision object + for entityIdx, decision := range entityDecisions { + 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 - resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied - - decision.Results[idx] = resourceDecision + } else { + // No required obligations means all obligations are satisfied + resourceDecision.ObligationsSatisfied = true } - decision.Access = allPermitted - entityRepID := entityRepresentations[entityIdx].GetOriginalId() - p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) + + resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied + decision.Results[idx] = resourceDecision } - return entityDecisions, allPermitted, nil - } - // Not entitled, so just log decisions to audit without setting obligations to the result - for entityIdx, entityRep := range entityRepresentations { - p.auditDecision(ctx, entityRep.GetOriginalId(), action, entityDecisions[entityIdx], entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) + decision.Access = allPermitted + entityRepID := entityRepresentations[entityIdx].GetOriginalId() + p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) } return entityDecisions, allPermitted, nil diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go index 8602f12a6f..60ac233114 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -210,7 +210,7 @@ func (p *ObligationsPolicyDecisionPoint) rollupResourceObligationDecisions( log.DebugContext( ctx, - "all triggered obligations reported as fulfillable", + "any triggered obligations reported as fulfillable", ) return results, true @@ -333,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( From f5651d69e17154c0af81c31f91c1c123f71167d8 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Tue, 21 Oct 2025 20:33:18 -0700 Subject: [PATCH 14/19] fix to handle no obligations pass cases --- .../internal/access/v2/just_in_time_pdp.go | 64 +++++++++---------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index e5813bf4de..8fa6a1cc04 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -181,24 +181,7 @@ func (p *JustInTimePDP) GetDecision( decision.Access = obligationDecision.AllObligationsSatisfied } - // Propagate obligations within policy on each resource decision object - 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 - decision.Results[idx] = resourceDecision - } - + decision = setResourceDecisionsWithObligations(decision, obligationDecision) p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) return []*Decision{decision}, decision.Access, nil @@ -237,23 +220,7 @@ func (p *JustInTimePDP) GetDecision( // Propagate obligations within policy on each resource decision object for entityIdx, decision := range entityDecisions { - 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 - decision.Results[idx] = resourceDecision - } - + decision = setResourceDecisionsWithObligations(decision, obligationDecision) decision.Access = allPermitted entityRepID := entityRepresentations[entityIdx].GetOriginalId() p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) @@ -262,6 +229,33 @@ func (p *JustInTimePDP) GetDecision( 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 + decision.Results[idx] = resourceDecision + } + 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( From eacfa41e1530fe45cab2299d57d117442981e4d9 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 22 Oct 2025 07:17:59 -0700 Subject: [PATCH 15/19] improve debug log --- service/internal/access/v2/pdp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index e4a3c97bf2..85aebe5ad3 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -212,7 +212,7 @@ func (p *PolicyDecisionPoint) GetDecision( l.DebugContext( ctx, "resourceDecision result", - slog.Bool("passed", resourceDecision.Entitled), + slog.Bool("entitled", resourceDecision.Entitled), slog.String("resource_id", resourceDecision.ResourceID), slog.Int("data_rule_results_count", len(resourceDecision.DataRuleResults)), ) From ee15d20bc1b112d7179827ab27d4b39ed301fbd3 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 22 Oct 2025 08:21:28 -0700 Subject: [PATCH 16/19] improve audit event metadata type --- service/logger/audit/getDecision.go | 44 +++++++++--------------- service/logger/audit/getDecision_test.go | 23 +++++++++---- service/logger/audit/logger_test.go | 19 +++++++--- service/logger/audit/rewrap.go | 4 +-- service/logger/audit/rewrap_test.go | 2 +- service/logger/audit/utils.go | 24 +++++++------ 6 files changed, 64 insertions(+), 52 deletions(-) diff --git a/service/logger/audit/getDecision.go b/service/logger/audit/getDecision.go index 11b3541130..fb1c67d13b 100644 --- a/service/logger/audit/getDecision.go +++ b/service/logger/audit/getDecision.go @@ -51,7 +51,7 @@ type GetDecisionV2EventParams struct { 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) { @@ -99,7 +99,7 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara result = ActionResultFailure } - actorAttributes := []interface{}{ + actorAttributes := []any{ struct { Entitlements subjectmappingbuiltin.AttributeValueFQNsToActions `json:"entitlements_relevant_to_decision"` }{ @@ -113,14 +113,10 @@ func CreateV2GetDecisionEvent(ctx context.Context, params GetDecisionV2EventPara } // Build event metadata with both resource decisions and obligations - eventMetadata := struct { - ResourceDecisions interface{} `json:"resource_decisions"` - FulfillableObligationValueFQNs []string `json:"fulfillable_obligation_value_fqns"` - ObligationsSatisfied bool `json:"obligations_satisfied"` - }{ - ResourceDecisions: params.ResourceDecisions, - FulfillableObligationValueFQNs: fulfillable, - ObligationsSatisfied: params.ObligationsSatisfied, + eventMetadata := auditEventMetadata{ + "resource_decisions": params.ResourceDecisions, + "fulfillable_obligation_value_fqns": fulfillable, + "obligations_satisfied": params.ObligationsSatisfied, } return &EventObject{ @@ -148,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"` @@ -164,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 9fcee5da3b..c9a32b1a91 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 f658cdf3f0..e2164fec66 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 a07cdc32dd..77dba335ba 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 6fa36b482c..5dae8c7a5d 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 fc3880c157..b8cbfe711b 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 { From b4d6af1633397099e53e30aff9301451113d5e8d Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 22 Oct 2025 08:27:29 -0700 Subject: [PATCH 17/19] var improvement --- service/internal/access/v2/obligations/obligations_pdp.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/internal/access/v2/obligations/obligations_pdp.go b/service/internal/access/v2/obligations/obligations_pdp.go index 60ac233114..eed1c752fe 100644 --- a/service/internal/access/v2/obligations/obligations_pdp.go +++ b/service/internal/access/v2/obligations/obligations_pdp.go @@ -182,7 +182,7 @@ func (p *ObligationsPolicyDecisionPoint) rollupResourceObligationDecisions( var unfulfilled []string results := make([]PerResourceDecision, len(perResourceTriggeredObligationValueFQNs)) for i, resourceTriggeredObligations := range perResourceTriggeredObligationValueFQNs { - allSatisfied := true + resourceSatisfied := true for _, triggered := range resourceTriggeredObligations { triggered = strings.ToLower(triggered) if _, ok := fulfillable[triggered]; !ok { @@ -190,11 +190,11 @@ func (p *ObligationsPolicyDecisionPoint) rollupResourceObligationDecisions( unfulfilledSeen[triggered] = struct{}{} unfulfilled = append(unfulfilled, triggered) } - allSatisfied = false + resourceSatisfied = false } } results[i] = PerResourceDecision{ - ObligationsSatisfied: allSatisfied, + ObligationsSatisfied: resourceSatisfied, RequiredObligationValueFQNs: resourceTriggeredObligations, } } From befb269fa9a0a4b8e6f35a264304640f28aba2ca Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 22 Oct 2025 08:27:49 -0700 Subject: [PATCH 18/19] pointer to slice struct instead of copy --- service/internal/access/v2/just_in_time_pdp.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index 8fa6a1cc04..e38f65cb8d 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -238,7 +238,7 @@ func setResourceDecisionsWithObligations( hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 for idx := range decision.Results { - resourceDecision := decision.Results[idx] + resourceDecision := &decision.Results[idx] if hasRequiredObligations { // Update with specific obligation data from the obligations PDP @@ -251,7 +251,6 @@ func setResourceDecisionsWithObligations( } resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied - decision.Results[idx] = resourceDecision } return decision } From 1d6f0548ca105290db00742ccf336046b2840607 Mon Sep 17 00:00:00 2001 From: jakedoublev Date: Wed, 22 Oct 2025 11:36:38 -0700 Subject: [PATCH 19/19] rename pdp decision field for clarity --- .../authorization/v2/authorization_test.go | 44 +++--- .../internal/access/v2/just_in_time_pdp.go | 26 ++-- service/internal/access/v2/pdp.go | 19 +-- service/internal/access/v2/pdp_test.go | 128 +++++++++--------- 4 files changed, 110 insertions(+), 107 deletions(-) diff --git a/service/authorization/v2/authorization_test.go b/service/authorization/v2/authorization_test.go index dc1f3ba98b..bbb4ec38ed 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/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index e38f65cb8d..5a0409fa77 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -176,14 +176,12 @@ func (p *JustInTimePDP) GetDecision( // Update resource decisions with obligations and set final access decision hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 - if decision.Access && hasRequiredObligations { - // Access should only be granted if entitled AND obligations fulfilled - decision.Access = obligationDecision.AllObligationsSatisfied - } - + entitledWithAnyObligationsSatisfied := decision.AllPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) + decision.AllPermitted = entitledWithAnyObligationsSatisfied decision = setResourceDecisionsWithObligations(decision, obligationDecision) + p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision) - return []*Decision{decision}, decision.Access, nil + return []*Decision{decision}, decision.AllPermitted, nil default: return nil, false, ErrInvalidEntityType @@ -204,7 +202,8 @@ func (p *JustInTimePDP) GetDecision( 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 @@ -213,15 +212,16 @@ func (p *JustInTimePDP) GetDecision( // Update resource decisions with obligations and set final access decision hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0 - if allPermitted && hasRequiredObligations { - // Only grant access if entitled AND obligations fulfilled - allPermitted = obligationDecision.AllObligationsSatisfied - } + allEntitledWithAnyObligationsSatisfied := allPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) + allPermitted = allEntitledWithAnyObligationsSatisfied // Propagate obligations within policy on each resource decision object for entityIdx, decision := range entityDecisions { + // TODO: figure out this multi-entity response? + // entitledWithAnyObligationsSatisfied := decision.AllPermitted && (!hasRequiredObligations || obligationDecision.AllObligationsSatisfied) + // decision.AllPermitted = entitledWithAnyObligationsSatisfied decision = setResourceDecisionsWithObligations(decision, obligationDecision) - decision.Access = allPermitted + decision.AllPermitted = allPermitted entityRepID := entityRepresentations[entityIdx].GetOriginalId() p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision) } @@ -439,7 +439,7 @@ func (p *JustInTimePDP) auditDecision( ) { // Determine audit decision result auditDecision := audit.GetDecisionResultDeny - if decision.Access { + if decision.AllPermitted { auditDecision = audit.GetDecisionResultPermit } diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 85aebe5ad3..dffb363838 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -19,8 +19,11 @@ import ( // 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. @@ -195,8 +198,8 @@ func (p *PolicyDecisionPoint) GetDecision( 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 { @@ -206,7 +209,7 @@ func (p *PolicyDecisionPoint) GetDecision( } if !resourceDecision.Entitled { - decision.Access = false + decision.AllPermitted = false } l.DebugContext( @@ -273,8 +276,8 @@ 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 { @@ -283,7 +286,7 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( return nil, nil, fmt.Errorf("error evaluating a decision on resource [%v]: %w", resource, err) } if !resourceDecision.Entitled { - decision.Access = false + decision.AllPermitted = false } l.DebugContext( diff --git a/service/internal/access/v2/pdp_test.go b/service/internal/access/v2/pdp_test.go index a43bb36bfd..0f8ed387c8 100644 --- a/service/internal/access/v2/pdp_test.go +++ b/service/internal/access/v2/pdp_test.go @@ -896,7 +896,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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{ @@ -937,7 +937,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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{ @@ -969,7 +969,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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{ @@ -1006,7 +1006,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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{ @@ -1033,7 +1033,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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{ @@ -1083,7 +1083,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 @@ -1131,7 +1131,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 @@ -1178,7 +1178,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 @@ -1226,7 +1226,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 @@ -1272,7 +1272,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 @@ -1319,7 +1319,7 @@ func (s *PDPTestSuite) Test_GetDecision_MultipleResources() { 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 @@ -1370,7 +1370,7 @@ func (s *PDPTestSuite) Test_GetDecision_ReturnsDecisionRelatedEntitlements() { s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access, "Entity should have access") + s.True(decision.AllPermitted, "Entity should have access") s.Require().NotNil(entitlements, "Entitlements should not be nil") @@ -1489,14 +1489,14 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { // 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) 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) }) @@ -1515,32 +1515,32 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { 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}) 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}) 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}) 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}) s.Require().NoError(err) s.Require().NotNil(decision) - s.False(decision.Access) + s.False(decision.AllPermitted) }) s.Run("Action inheritance with partial permissions", func() { @@ -1555,31 +1555,31 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { 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) 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) 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) 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) 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() { @@ -1631,19 +1631,19 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { 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) 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) 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() { @@ -1666,19 +1666,19 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { 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) 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) 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() { @@ -1701,19 +1701,19 @@ func (s *PDPTestSuite) Test_GetDecision_PartialActionEntitlement() { 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) 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) s.Require().NoError(err) s.Require().NotNil(decision) - s.True(decision.Access) + s.True(decision.AllPermitted) }) } @@ -1750,19 +1750,19 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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}) 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}) 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() { @@ -1779,13 +1779,13 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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}) 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() { @@ -1802,13 +1802,13 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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}) 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() { @@ -1826,7 +1826,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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 @@ -1861,14 +1861,14 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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}) 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) } }) @@ -1887,7 +1887,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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) @@ -1931,13 +1931,13 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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}) 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) @@ -1982,7 +1982,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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{}{ @@ -1993,7 +1993,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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) @@ -2020,7 +2020,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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{}{ @@ -2031,7 +2031,7 @@ func (s *PDPTestSuite) Test_GetDecision_CombinedAttributeRules_SingleResource() 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) @@ -2109,7 +2109,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { 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 @@ -2135,7 +2135,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { 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) @@ -2166,7 +2166,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { 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 @@ -2199,7 +2199,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { 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 @@ -2236,7 +2236,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { 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) @@ -2274,7 +2274,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { 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{ @@ -2291,7 +2291,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { // 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 @@ -2326,7 +2326,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { 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) @@ -2345,7 +2345,7 @@ func (s *PDPTestSuite) Test_GetDecision_AcrossNamespaces() { // 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) @@ -2457,7 +2457,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { 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{ @@ -2485,7 +2485,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { 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{ @@ -2511,7 +2511,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { 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{ @@ -2543,7 +2543,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { 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{ @@ -2564,7 +2564,7 @@ func (s *PDPTestSuite) Test_GetDecisionRegisteredResource_MultipleResources() { 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{