Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 12 additions & 29 deletions service/internal/access/v2/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func getResourceDecision(
action *policy.Action,
resource *authz.Resource,
namespacedPolicy bool,
hierarchies map[string]*hierarchyEntitlement,
) (*ResourceDecision, error) {
var (
resourceID = resource.GetEphemeralId()
Expand Down Expand Up @@ -140,7 +141,7 @@ func getResourceDecision(
return failure, nil
}

return evaluateResourceAttributeValues(ctx, l, resourceAttributeValues, resourceID, registeredResourceValueFQN, action, entitlements, accessibleAttributeValues, namespacedPolicy)
return evaluateResourceAttributeValues(ctx, l, resourceAttributeValues, resourceID, registeredResourceValueFQN, action, entitlements, accessibleAttributeValues, namespacedPolicy, hierarchies)
}

// evaluateResourceAttributeValues evaluates a list of attribute values against the action and entitlements
Expand All @@ -155,6 +156,7 @@ func evaluateResourceAttributeValues(
entitlements subjectmappingbuiltin.AttributeValueFQNsToActions,
accessibleAttributeValues map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue,
namespacedPolicy bool,
hierarchies map[string]*hierarchyEntitlement,
) (*ResourceDecision, error) {
// Group value FQNs by parent definition
definitionFqnToValueFqns := make(map[string][]string)
Expand Down Expand Up @@ -197,7 +199,7 @@ func evaluateResourceAttributeValues(
return nil, fmt.Errorf("%w: %s", ErrDefinitionNotFound, defFQN)
}

dataRuleResult, err := evaluateDefinition(ctx, l, entitlements, action, resourceValueFQNs, definition, namespacedPolicy)
dataRuleResult, err := evaluateDefinition(ctx, l, entitlements, action, resourceValueFQNs, definition, namespacedPolicy, hierarchies)
if err != nil {
return nil, errors.Join(ErrFailedEvaluation, err)
}
Expand Down Expand Up @@ -228,6 +230,7 @@ func evaluateDefinition(
resourceValueFQNs []string,
attrDefinition *policy.Attribute,
namespacedPolicy bool,
hierarchies map[string]*hierarchyEntitlement,
) (*DataRuleResult, error) {
var entitlementFailures []EntitlementFailure
namespaceFQN := attrDefinition.GetNamespace().GetFqn()
Expand All @@ -249,7 +252,7 @@ func evaluateDefinition(
entitlementFailures = anyOfRule(ctx, l, entitlements, action, resourceValueFQNs, namespaceFQN, namespacedPolicy)

case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY:
entitlementFailures = hierarchyRule(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, namespaceFQN, namespacedPolicy)
entitlementFailures = hierarchyRule(ctx, l, entitlements, action, resourceValueFQNs, attrDefinition, namespaceFQN, namespacedPolicy, hierarchies[attrDefinition.GetFqn()])

case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED:
return nil, fmt.Errorf("%w: %s, rule: %s", ErrMissingRequiredSpecifiedRule, attrDefinition.GetFqn(), attrDefinition.GetRule().String())
Expand Down Expand Up @@ -382,6 +385,7 @@ func hierarchyRule(
attrDefinition *policy.Attribute,
requiredNamespaceFQN string,
namespacedPolicy bool,
prepared *hierarchyEntitlement,
) []EntitlementFailure {
// No resources to check
if len(resourceValueFQNs) == 0 {
Expand All @@ -391,11 +395,10 @@ func hierarchyRule(
actionName := action.GetName()
attrValues := attrDefinition.GetValues()

// Create a lookup map for the attribute value indices - O(n) where n is the number of values in the attribute
valueFQNToIndex := make(map[string]int, len(attrValues))
for idx, value := range attrValues {
valueFQNToIndex[value.GetFqn()] = idx
if prepared == nil {
prepared = newHierarchyEntitlement(ctx, l, attrDefinition, entitlements, action, requiredNamespaceFQN, namespacedPolicy, nil)
}
valueFQNToIndex := prepared.ranks

// Find the lowest indexed value FQN (highest in hierarchy) - O(m) where m is the number of resource values
lowestValueFQNIndex := len(attrValues)
Expand All @@ -405,28 +408,8 @@ func hierarchyRule(
}
}

// Check if the entitlements contain any values with index <= lowestValueFQNIndex
// This checks the requested value and any hierarchically higher values in a single pass - O(e) where e is entitlements count
for entitlementFQN, entitledActions := range entitlements {
// Check if this entitlement FQN has a valid index in the hierarchy
if idx, exists := valueFQNToIndex[entitlementFQN]; exists && idx <= lowestValueFQNIndex {
// Check if the required action is entitled
for _, entitledAction := range entitledActions {
if isRequestedActionMatch(ctx, l, action, requiredNamespaceFQN, entitledAction, namespacedPolicy) {
l.DebugContext(ctx, "hierarchy rule satisfied",
slog.Group("entitled_by_value",
slog.String("FQN", entitlementFQN),
slog.Int("index", idx),
),
slog.Group("resource_highest_hierarchy_value",
slog.String("FQN", attrValues[lowestValueFQNIndex].GetFqn()),
slog.Int("index", lowestValueFQNIndex),
),
)
return nil // Found an entitled action at or above the hierarchy level, no failures
}
}
}
if prepared.highest < len(attrValues) && prepared.highest <= lowestValueFQNIndex {
return nil
}

// The rule was not satisfied - collect failures - O(m) where m is the number of resource values
Expand Down
20 changes: 10 additions & 10 deletions service/internal/access/v2/evaluate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ func (s *EvaluateTestSuite) TestHierarchyRule() {
for _, tc := range tests {
s.Run(tc.name, func() {
// Execute
failures := hierarchyRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs, s.hierarchicalClassAttr, "", false)
failures := hierarchyRule(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValueFQNs, s.hierarchicalClassAttr, "", false, nil)

// Assert
if tc.expectedFailures {
Expand Down Expand Up @@ -729,7 +729,7 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition() {

for _, tc := range tests {
s.Run(tc.name, func() {
result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValues, tc.definition, false)
result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, s.action, tc.resourceValues, tc.definition, false, nil)

if tc.expectError {
s.Require().Error(err)
Expand Down Expand Up @@ -913,7 +913,7 @@ func (s *EvaluateTestSuite) TestEvaluateDefinition_NamespacedPolicy() {

for _, tc := range tests {
s.Run(tc.name, func() {
result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, tc.requested, tc.resourceFQNs, tc.definition, tc.namespaced)
result, err := evaluateDefinition(s.T().Context(), s.logger, tc.entitlements, tc.requested, tc.resourceFQNs, tc.definition, tc.namespaced, nil)

if tc.expectErr != nil {
s.Require().Error(err)
Expand Down Expand Up @@ -1010,7 +1010,7 @@ func (s *EvaluateTestSuite) TestEvaluateResourceAttributeValues() {
s.action,
tc.entitlements,
s.accessibleAttrValues,
false,
false, nil,
)

if tc.expectError {
Expand Down Expand Up @@ -1266,7 +1266,7 @@ func (s *EvaluateTestSuite) TestGetResourceDecision() {
tc.entitlements,
s.action,
tc.resource,
tc.namespaced,
tc.namespaced, nil,
)

if tc.expectError {
Expand Down Expand Up @@ -1339,7 +1339,7 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_MultiResources_GranularDeni
entitlements,
s.action,
tc.resource,
false,
false, nil,
)

s.Require().NoError(err, "Should not error for resource: %s", tc.name)
Expand Down Expand Up @@ -1376,7 +1376,7 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_DeniesOnActionNa
entitlements,
actionRead,
resource,
true,
true, nil,
)

s.Require().NoError(err)
Expand Down Expand Up @@ -1430,7 +1430,7 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_RegisteredResour
entitlements,
actionRead,
resource,
true,
true, nil,
)

s.Require().NoError(err)
Expand Down Expand Up @@ -1464,7 +1464,7 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_RequestActionIDPrecedence()
entitlements,
&policy.Action{Id: "different-id", Name: actions.ActionNameRead},
resource,
true,
true, nil,
)

s.Require().NoError(err)
Expand Down Expand Up @@ -1528,7 +1528,7 @@ func (s *EvaluateTestSuite) Test_getResourceDecision_StrictMode_ErrorsOnAAVNames
entitlements,
actionRead,
resource,
true,
true, nil,
)

s.Require().Error(err)
Expand Down
49 changes: 49 additions & 0 deletions service/internal/access/v2/hierarchy_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package access

import (
"fmt"
"log/slog"
"testing"

authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2"
"github.com/opentdf/platform/protocol/go/policy"
"github.com/opentdf/platform/service/logger"
"github.com/opentdf/platform/service/logger/audit"
)

func BenchmarkDecisionHierarchy(b *testing.B) {
for _, values := range []int{1000, 6000} {
b.Run(fmt.Sprintf("values=%d/resources=1000", values), func(b *testing.B) {
const def = "https://scale.example/attr/clearance"
attr := &policy.Attribute{Fqn: def, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, Values: make([]*policy.Value, values)}
for i := range attr.GetValues() {
attr.Values[i] = &policy.Value{Fqn: fmt.Sprintf("%s/value/%d", def, i)}
}
mappings := []*policy.SubjectMapping{{Id: "mapping", AttributeValue: attr.GetValues()[0], SubjectConditionSet: clientIDInConditionSet("abc"), Actions: []*policy.Action{{Name: "read"}}}}
log := slog.New(slog.DiscardHandler)
l := &logger.Logger{Logger: log, Audit: audit.CreateAuditLogger(*log)}
pdp, err := NewPolicyDecisionPoint(b.Context(), l, []*policy.Attribute{attr}, mappings, nil, false, false)
if err != nil {
b.Fatal(err)
}
resources := make([]*authzV2.Resource, 1000)
for i := range resources {
resources[i] = attrValueResource(attr.GetValues()[values-1].GetFqn())[0]
resources[i].EphemeralId = fmt.Sprintf("resource-%d", i)
}
representation := entityRepWithClientID("abc")
action := &policy.Action{Name: "read"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
decision, _, err := pdp.GetDecision(b.Context(), representation, action, resources)
if err != nil {
b.Fatal(err)
}
if !decision.AllPermitted || len(decision.Results) != len(resources) {
b.Fatal("unexpected decision")
}
}
})
}
}
62 changes: 62 additions & 0 deletions service/internal/access/v2/hierarchy_index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package access

import (
"context"

"github.com/opentdf/platform/protocol/go/policy"
attrs "github.com/opentdf/platform/protocol/go/policy/attributes"
"github.com/opentdf/platform/service/internal/subjectmappingbuiltin"
"github.com/opentdf/platform/service/logger"
)

type hierarchyEntitlement struct {
ranks map[string]int
highest int
}

func hierarchyRanks(definition *policy.Attribute) map[string]int {
ranks := make(map[string]int, len(definition.GetValues()))
for i, value := range definition.GetValues() {
ranks[value.GetFqn()] = i
}
return ranks
}

func newHierarchyEntitlement(ctx context.Context, l *logger.Logger, definition *policy.Attribute, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action, namespace string, namespaced bool, ranks map[string]int) *hierarchyEntitlement {
if ranks == nil {
ranks = hierarchyRanks(definition)
}
prepared := &hierarchyEntitlement{ranks: ranks, highest: len(definition.GetValues())}
for fqn, actions := range entitlements {
rank, found := ranks[fqn]
if !found || rank >= prepared.highest {
continue
}
for _, entitledAction := range actions {
if isRequestedActionMatch(ctx, l, action, namespace, entitledAction, namespaced) {
prepared.highest = rank
break
}
}
}
return prepared
}

func (p *PolicyDecisionPoint) prepareHierarchyEntitlements(ctx context.Context, decisionable map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, entitlements subjectmappingbuiltin.AttributeValueFQNsToActions, action *policy.Action) map[string]*hierarchyEntitlement {
var prepared map[string]*hierarchyEntitlement
for _, pair := range decisionable {
definition := pair.GetAttribute()
if definition.GetRule() != policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY {
continue
}
fqn := definition.GetFqn()
if _, exists := prepared[fqn]; exists {
continue
}
if prepared == nil {
prepared = make(map[string]*hierarchyEntitlement)
}
prepared[fqn] = newHierarchyEntitlement(ctx, p.logger, definition, entitlements, action, definition.GetNamespace().GetFqn(), p.namespacedPolicy, p.hierarchyRanksByDefinition[fqn])
}
return prepared
}
41 changes: 41 additions & 0 deletions service/internal/access/v2/hierarchy_index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package access

import (
"log/slog"
"testing"

"github.com/opentdf/platform/protocol/go/policy"
"github.com/opentdf/platform/service/logger"
"github.com/opentdf/platform/service/logger/audit"
"github.com/stretchr/testify/require"
)

func TestHierarchyEntitlementsRemainDecisionScoped(t *testing.T) {
const definition = "https://scale.example/attr/clearance"
high := &policy.Value{Fqn: definition + "/value/high"}
low := &policy.Value{Fqn: definition + "/value/low"}
attr := &policy.Attribute{Fqn: definition, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, Values: []*policy.Value{high, low}}
mapping := &policy.SubjectMapping{Id: "high-read", AttributeValue: high, SubjectConditionSet: clientIDInConditionSet("authorized"), Actions: []*policy.Action{{Name: "read"}}}
log := slog.New(slog.DiscardHandler)
l := &logger.Logger{Logger: log, Audit: audit.CreateAuditLogger(*log)}
pdp, err := NewPolicyDecisionPoint(t.Context(), l, []*policy.Attribute{attr}, []*policy.SubjectMapping{mapping}, nil, false, false)
require.NoError(t, err)
for _, tc := range []struct {
client string
action string
permit bool
}{
{"authorized", "read", true},
{"authorized", "write", false},
{"unauthorized", "read", false},
{"authorized", "read", true},
} {
resources := append(attrValueResource(high.GetFqn()), attrValueResource(low.GetFqn())...)
decision, _, err := pdp.GetDecision(t.Context(), entityRepWithClientID(tc.client), &policy.Action{Name: tc.action}, resources)
require.NoError(t, err)
require.Equal(t, tc.permit, decision.AllPermitted, "%s/%s", tc.client, tc.action)
for _, result := range decision.Results {
require.Equal(t, tc.permit, result.Entitled)
}
}
}
1 change: 1 addition & 0 deletions service/internal/access/v2/just_in_time_pdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ func (p *JustInTimePDP) getMatchedSubjectMappings(
}
for _, item := range flattened.Items {
if _, ok := subjectPropertySet[item.Key]; !ok {
subjectPropertySet[item.Key] = struct{}{}
subjectProperties = append(subjectProperties, &policy.SubjectProperty{
ExternalSelectorValue: item.Key,
})
Expand Down
10 changes: 10 additions & 0 deletions service/internal/access/v2/just_in_time_pdp_targeted_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,13 @@ func TestJITPDP_GetDecision_TargetedDenyOnUnknownFQN(t *testing.T) {
require.NotNil(t, decision)
assert.False(t, decision.AllPermitted)
}

func TestMatchedSubjectMappingsDeduplicatesSelectorsAcrossRepresentations(t *testing.T) {
fake := &fakeSubjectMappingClient{resp: &subjectmapping.MatchSubjectMappingsResponse{}}
pdp := &JustInTimePDP{sdk: &otdfSDK.SDK{SubjectMapping: fake}}
_, err := pdp.getMatchedSubjectMappings(t.Context(), []*entityresolutionV2.EntityRepresentation{entityRepWithClientID("one"), entityRepWithClientID("two")})
require.NoError(t, err)
require.Len(t, fake.requests, 1)
require.Len(t, fake.requests[0].GetSubjectProperties(), 1)
require.Equal(t, ".clientId", fake.requests[0].GetSubjectProperties()[0].GetExternalSelectorValue())
}
Loading
Loading