Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
58 changes: 49 additions & 9 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,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)
}
Expand All @@ -175,15 +176,17 @@ func (p *JustInTimePDP) GetDecision(

// If not entitled, obligations are not considered
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 = allTriggeredObligationsCanBeFulfilled
for idx, required := range requiredObligationsPerResource {
decision.Access = obligationDecision.AllObligationsAreFulfilled
for idx, required := range obligationDecision.RequiredObligationValueFQNsPerResource {
decision.Results[idx].RequiredObligationValueFQNs = required
}

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

default:
Expand All @@ -195,9 +198,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,22 +212,32 @@ 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], fulfillableObligationValueFQNs, 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
}
}

// 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
}

Expand Down Expand Up @@ -287,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)
Expand Down Expand Up @@ -400,3 +412,31 @@ 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 && obligationDecision.AllObligationsAreFulfilled {
auditDecision = audit.GetDecisionResultPermit
}

p.logger.Audit.GetDecisionV2(ctx, audit.GetDecisionV2EventParams{
EntityID: entityID,
ActionName: action.GetName(),
Decision: auditDecision,
Entitlements: entitlements,
FulfillableObligationValueFQNs: fulfillableObligationValueFQNs,
RequiredObligationValueFQNs: obligationDecision.RequiredObligationValueFQNs,
ObligationsSatisfied: obligationDecision.AllObligationsAreFulfilled,
ResourceDecisions: decision.Results,
})
}
23 changes: 17 additions & 6 deletions service/internal/access/v2/obligations/obligations_pdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
Expand Down
40 changes: 13 additions & 27 deletions service/internal/access/v2/pdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))

Expand All @@ -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 {
Expand All @@ -216,45 +215,32 @@ 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(
ctx context.Context,
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)))

Expand Down Expand Up @@ -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
Expand All @@ -306,7 +292,7 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource(
decision.Results[idx] = *resourceDecision
}

return decision, nil
return decision, entitledFQNsToActions, nil
}

func (p *PolicyDecisionPoint) GetEntitlements(
Expand Down
Loading
Loading