Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5b5b234
feat(authz): audit logs should properly handle obligations
jakedoublev Oct 17, 2025
2278200
Merge remote-tracking branch 'origin' into feat/DSPX-1735-audit
jakedoublev Oct 21, 2025
4ac327f
improve obligation decision result and audit logic
jakedoublev Oct 21, 2025
9875394
cleanup
jakedoublev Oct 21, 2025
7e2472b
Merge remote-tracking branch 'origin' into feat/DSPX-1735-audit
jakedoublev Oct 21, 2025
12d153d
isolated unit test that entitlements come back for audit
jakedoublev Oct 21, 2025
061b9fb
gemini suggestion unused param
jakedoublev Oct 21, 2025
c46dec2
improve tests
jakedoublev Oct 21, 2025
46d1c44
audit log should have empty fulfillable obligations and not nil when …
jakedoublev Oct 21, 2025
ea87577
improved logs and resource-level decisioning in response
jakedoublev Oct 21, 2025
3088d71
cleanup
jakedoublev Oct 21, 2025
dca8546
fix multiresource obligations decisioning
jakedoublev Oct 22, 2025
e814ddf
cleanup
jakedoublev Oct 22, 2025
e92f6f7
cleanup
jakedoublev Oct 22, 2025
203e7e2
Merge remote-tracking branch 'origin' into feat/DSPX-1735-audit
jakedoublev Oct 22, 2025
967dcb3
working
jakedoublev Oct 22, 2025
f5651d6
fix to handle no obligations pass cases
jakedoublev Oct 22, 2025
eacfa41
improve debug log
jakedoublev Oct 22, 2025
ee15d20
improve audit event metadata type
jakedoublev Oct 22, 2025
b4d6af1
var improvement
jakedoublev Oct 22, 2025
befb269
pointer to slice struct instead of copy
jakedoublev Oct 22, 2025
1d6f054
rename pdp decision field for clarity
jakedoublev Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions service/internal/access/v2/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -151,7 +151,7 @@ func evaluateResourceAttributeValues(

// Return results in the appropriate structure
result := &ResourceDecision{
Passed: passed,
Entitled: passed,
ResourceID: resourceID,
DataRuleResults: dataRuleResults,
}
Expand Down
4 changes: 2 additions & 2 deletions service/internal/access/v2/evaluate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
})
Expand Down
102 changes: 78 additions & 24 deletions service/internal/access/v2/just_in_time_pdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -141,7 +142,7 @@ func (p *JustInTimePDP) GetDecision(
)

// Because there are three possible types of entities, check obligations first to more easily handle decisioning logic
allTriggeredObligationsCanBeFulfilled, requiredObligationsPerResource, err := p.obligationsPDP.GetAllTriggeredObligationsAreFulfilled(
obligationDecision, err := p.obligationsPDP.GetAllTriggeredObligationsAreFulfilled(
ctx,
resources,
action,
Expand All @@ -165,25 +166,23 @@ 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)
}
if decision == nil {
return nil, false, fmt.Errorf("decision is nil for registered resource value FQN [%s]", regResValueFQN)
}

// If not entitled, obligations are not considered
if !decision.Access {
return []*Decision{decision}, decision.Access, nil
}

// Access should only be granted if entitled AND obligations fulfilled
decision.Access = allTriggeredObligationsCanBeFulfilled
for idx, required := range requiredObligationsPerResource {
decision.Results[idx].RequiredObligationValueFQNs = required
// Update resource decisions with obligations and set final access decision
hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0
if decision.Access && hasRequiredObligations {
// Access should only be granted if entitled AND obligations fulfilled
decision.Access = obligationDecision.AllObligationsSatisfied
}

decision = setResourceDecisionsWithObligations(decision, obligationDecision)
p.auditDecision(ctx, regResValueFQN, action, decision, entitlements, fulfillableObligationValueFQNs, obligationDecision)
return []*Decision{decision}, decision.Access, nil

default:
Expand All @@ -195,9 +194,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)
}
Expand All @@ -208,25 +208,54 @@ 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 {
return entityDecisions, allPermitted, nil
// 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
}

// Access should only be granted if entitled AND obligations fulfilled
allPermitted = allTriggeredObligationsCanBeFulfilled
// Obligations are not entity-specific at this time so will be the same across every entity
for _, decision := range entityDecisions {
for idx, required := range requiredObligationsPerResource {
decision.Results[idx].RequiredObligationValueFQNs = required
}
// Propagate obligations within policy on each resource decision object
for entityIdx, decision := range entityDecisions {
decision = setResourceDecisionsWithObligations(decision, obligationDecision)
decision.Access = allPermitted
entityRepID := entityRepresentations[entityIdx].GetOriginalId()
p.auditDecision(ctx, entityRepID, action, decision, entityEntitlements[entityIdx], fulfillableObligationValueFQNs, obligationDecision)
}

return entityDecisions, allPermitted, nil
}

// setResourceDecisionsWithObligations updates all resource decisions with obligation
// information and sets each resource passed state
func setResourceDecisionsWithObligations(
decision *Decision,
obligationDecision obligations.ObligationPolicyDecision,
) *Decision {
hasRequiredObligations := len(obligationDecision.RequiredObligationValueFQNs) > 0

for idx := range decision.Results {
resourceDecision := decision.Results[idx]

if hasRequiredObligations {
// Update with specific obligation data from the obligations PDP
perResource := obligationDecision.RequiredObligationValueFQNsPerResource[idx]
resourceDecision.ObligationsSatisfied = perResource.ObligationsSatisfied
resourceDecision.RequiredObligationValueFQNs = perResource.RequiredObligationValueFQNs
} else {
// No required obligations means all obligations are satisfied
resourceDecision.ObligationsSatisfied = true
}

resourceDecision.Passed = resourceDecision.Entitled && resourceDecision.ObligationsSatisfied
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(
Expand Down Expand Up @@ -287,8 +316,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)
Expand Down Expand Up @@ -400,3 +427,30 @@ func (p *JustInTimePDP) resolveEntitiesFromRequestToken(

return p.resolveEntitiesFromToken(ctx, token, skipEnvironmentEntities)
}

// auditDecision logs a GetDecisionV2 audit event with obligation information
func (p *JustInTimePDP) auditDecision(
ctx context.Context,
entityID string,
action *policy.Action,
decision *Decision,
entitlements map[string][]*policy.Action,
fulfillableObligationValueFQNs []string,
obligationDecision obligations.ObligationPolicyDecision,
) {
// Determine audit decision result
auditDecision := audit.GetDecisionResultDeny
if decision.Access {
auditDecision = audit.GetDecisionResultPermit
}

p.logger.Audit.GetDecisionV2(ctx, audit.GetDecisionV2EventParams{
EntityID: entityID,
ActionName: action.GetName(),
Decision: auditDecision,
Entitlements: entitlements,
FulfillableObligationValueFQNs: fulfillableObligationValueFQNs,
ObligationsSatisfied: obligationDecision.AllObligationsSatisfied,
ResourceDecisions: decision.Results,
})
}
75 changes: 53 additions & 22 deletions service/internal/access/v2/obligations/obligations_pdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ type ObligationsPolicyDecisionPoint struct {
clientIDScopedTriggerActionsToAttributes map[string]obligationValuesByActionOnAnAttributeValue
}

type PerResourceDecision struct {
// Whether or not all obligations triggered for the resource can be fulfilled by the caller
ObligationsSatisfied bool
// The Set of obligations required on this indexed resource
RequiredObligationValueFQNs []string
}

type ObligationPolicyDecision struct {
// Whether or not all the obligations that were triggered can be fulfilled by the caller
AllObligationsSatisfied bool
// The Set of obligations required across all resources in the decision
RequiredObligationValueFQNs []string
// The Set of obligations required on each indexed resource
RequiredObligationValueFQNsPerResource []PerResourceDecision
}

func NewObligationsPolicyDecisionPoint(
ctx context.Context,
l *logger.Logger,
Expand Down Expand Up @@ -120,38 +136,40 @@ func NewObligationsPolicyDecisionPoint(
// 4. the obligation value FQNs a PEP is capable of fulfilling (self-reported)
//
// It will check the action, resources, and decision request context for the obligation values triggered,
// compare the PEP fulfillable obligations against those that have been triggered as required,
// and return whether or not all triggered obligations can be fulfilled along with the set of obligation FQNs
// the PEP must fulfill for each resource in the provided list.
// then compare the PEP fulfillable obligations against those that have been triggered as required.
func (p *ObligationsPolicyDecisionPoint) GetAllTriggeredObligationsAreFulfilled(
ctx context.Context,
resources []*authz.Resource,
action *policy.Action,
decisionRequestContext *policy.RequestContext,
pepFulfillableObligationValueFQNs []string,
) (bool, [][]string, error) {
perResource, allTriggered, err := p.getTriggeredObligations(ctx, action, resources, decisionRequestContext)
) (ObligationPolicyDecision, error) {
perResourceTriggered, allTriggered, err := p.getTriggeredObligations(ctx, action, resources, decisionRequestContext)
if err != nil {
return false, nil, err
return ObligationPolicyDecision{}, err
}

allFulfilled := p.getAllObligationsAreFulfilled(ctx, action, allTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext)
return allFulfilled, perResource, nil
perResourceDecisions, allFulfilled := p.rollupResourceObligationDecisions(ctx, action, perResourceTriggered, pepFulfillableObligationValueFQNs, decisionRequestContext)
return ObligationPolicyDecision{
AllObligationsSatisfied: allFulfilled,
RequiredObligationValueFQNs: allTriggered,
RequiredObligationValueFQNsPerResource: perResourceDecisions,
}, nil
}

// getAllObligationsAreFulfilled checks the deduplicated list of triggered obligations against the PEP
// self-reported fulfillable obligations to validate the PEP can fulfill all that were triggered.
// rollupResourceObligationDecisions checks the per-resource list of triggered obligations against the PEP
// self-reported fulfillable obligations to validate the PEP can fulfill those triggered on each resource
//
// While this is a simple check now, enhancements in types of obligations and the fulfillment source of truth
// (such as a PEP registration or centralized config) will add complexity to this validation. The RequestContext
// itself may sometimes contain information that may fulfill the obligation in the future.
func (p *ObligationsPolicyDecisionPoint) getAllObligationsAreFulfilled(
func (p *ObligationsPolicyDecisionPoint) rollupResourceObligationDecisions(
ctx context.Context,
action *policy.Action,
allTriggeredObligationValueFQNs []string,
perResourceTriggeredObligationValueFQNs [][]string,
pepFulfillableObligationValueFQNs []string,
decisionRequestContext *policy.RequestContext,
) bool {
) ([]PerResourceDecision, bool) {
log := loggerWithAttributes(p.logger, strings.ToLower(action.GetName()), decisionRequestContext.GetPep().GetClientId())

fulfillable := make(map[string]struct{})
Expand All @@ -160,29 +178,42 @@ func (p *ObligationsPolicyDecisionPoint) getAllObligationsAreFulfilled(
fulfillable[obligation] = struct{}{}
}

unfulfilledSeen := make(map[string]struct{})
var unfulfilled []string
for _, obligated := range allTriggeredObligationValueFQNs {
obligated = strings.ToLower(obligated)
if _, found := fulfillable[obligated]; !found {
unfulfilled = append(unfulfilled, obligated)
results := make([]PerResourceDecision, len(perResourceTriggeredObligationValueFQNs))
for i, resourceTriggeredObligations := range perResourceTriggeredObligationValueFQNs {
allSatisfied := true
for _, triggered := range resourceTriggeredObligations {
triggered = strings.ToLower(triggered)
if _, ok := fulfillable[triggered]; !ok {
if _, seen := unfulfilledSeen[triggered]; !seen {
unfulfilledSeen[triggered] = struct{}{}
unfulfilled = append(unfulfilled, triggered)
}
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",
"any triggered obligations reported as fulfillable",
)

return true
return results, true
}

// getTriggeredObligations takes in an action and multiple resources subject to decisioning.
Expand Down Expand Up @@ -302,7 +333,7 @@ func (p *ObligationsPolicyDecisionPoint) getTriggeredObligations(

log.DebugContext(
ctx,
"found required obligations",
"checked required obligations",
slog.Any("deduplicated_request_obligations_across_all_resources", allRequiredOblValueFQNs),
)
log.TraceContext(
Expand Down
Loading
Loading