Repository: opentdf/platform
Pull request #2223: feat(authz): subject mapping plugin support for ABAC with actions
Author: login: jakedoublev
Created: 2025-05-16T04:04:38Z | Merged: 2025-05-19T15:40:11Z

### Proposed Changes

* enhance `subjectmappingplugin` with actions in response to subject mapping evaluation

### Checklist

- [X] I have added or updated unit tests
- [ ] I have added or updated integration tests (if appropriate)
- [ ] I have added or updated documentation

### Testing Instructions




Diff:
diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go
new file mode 100644
index 0000000000..491d1bb8f7
--- /dev/null
+++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go
@@ -0,0 +1,86 @@
+package subjectmappingbuiltin
+
+import (
+	"fmt"
+	"maps"
+	"slices"
+	"strings"
+
+	"github.com/opentdf/platform/lib/flattening"
+	entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2"
+	"github.com/opentdf/platform/protocol/go/policy"
+	"github.com/opentdf/platform/protocol/go/policy/attributes"
+)
+
+type AttributeValueFQNsToActions map[string][]*policy.Action
+
+type EntityIDsToEntitlements map[string]AttributeValueFQNsToActions
+
+func EvaluateSubjectMappingMultipleEntitiesWithActions(
+	attributeMappings map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue,
+	entityRepresentations []*entityresolutionV2.EntityRepresentation,
+) (EntityIDsToEntitlements, error) {
+	results := make(map[string]AttributeValueFQNsToActions, len(entityRepresentations))
+	for _, er := range entityRepresentations {
+		entitlements, err := EvaluateSubjectMappingsWithActions(attributeMappings, er)
+		if err != nil {
+			return nil, err
+		}
+		results[er.GetOriginalId()] = entitlements
+	}
+
+	return results, nil
+}
+
+// Returns a map of attribute value FQNs to each entitled action on the value
+func EvaluateSubjectMappingsWithActions(
+	resolveableAttributes map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue,
+	entityRepresentation *entityresolutionV2.EntityRepresentation,
+) (AttributeValueFQNsToActions, error) {
+	jsonEntities := entityRepresentation.GetAdditionalProps()
+	entitlementsSet := make(map[string][]*policy.Action)
+
+	for _, entity := range jsonEntities {
+		flattenedEntity, err := flattening.Flatten(entity.AsMap())
+		if err != nil {
+			return nil, fmt.Errorf("failure to flatten entity in subject mapping builtin: %w", err)
+		}
+
+		for valueFQN, attributeAndValue := range resolveableAttributes {
+			// subject mapping results or-ed together
+			for _, subjectMapping := range attributeAndValue.GetValue().GetSubjectMappings() {
+				subjectMappingResult := true
+				for _, subjectSet := range subjectMapping.GetSubjectConditionSet().GetSubjectSets() {
+					subjectSetConditionResult, err := EvaluateSubjectSet(subjectSet, flattenedEntity)
+					if err != nil {
+						return nil, err
+					}
+					// update the result for the subject mapping
+					subjectMappingResult = subjectMappingResult && subjectSetConditionResult
+					// if one subject condition set fails, subject mapping fails
+					if !subjectSetConditionResult {
+						break
+					}
+				}
+
+				// each subject mapping that is true should permit the actions on the mapped value
+				if subjectMappingResult {
+					// add value FQN to the entitlements set
+					if _, ok := entitlementsSet[valueFQN]; !ok {
+						entitlementsSet[valueFQN] = make([]*policy.Action, 0)
+					}
+					actions := subjectMapping.GetActions()
+
+					// Cache each action by name to deduplicate
+					m := make(map[string]*policy.Action, len(actions))
+					for _, action := range actions {
+						m[strings.ToLower(action.GetName())] = action
+					}
+					entitlementsSet[valueFQN] = append(entitlementsSet[valueFQN], slices.Collect(maps.Values(m))...)
+				}
+			}
+		}
+	}
+
+	return entitlementsSet, nil
+}
diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go
new file mode 100644
index 0000000000..2b5a86c1a4
--- /dev/null
+++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions_test.go
@@ -0,0 +1,595 @@
+package subjectmappingbuiltin_test
+
+import (
+	"testing"
+
+	entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2"
+	"github.com/opentdf/platform/protocol/go/policy"
+	"github.com/opentdf/platform/protocol/go/policy/attributes"
+	"github.com/opentdf/platform/service/internal/subjectmappingbuiltin"
+	"github.com/opentdf/platform/service/policy/actions"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/types/known/structpb"
+)
+
+var (
+	classIntFQN        = "https://example.com/attr/class/value/internal"
+	classConfFQN       = "https://example.com/attr/class/value/conf"
+	classRestrictedFQN = "https://example.com/attr/class/value/restricted"
+
+	departmentEngineeringSM = &policy.SubjectMapping{
+		SubjectConditionSet: &policy.SubjectConditionSet{
+			SubjectSets: []*policy.SubjectSet{
+				{
+					ConditionGroups: []*policy.ConditionGroup{
+						{
+							BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND,
+							Conditions: []*policy.Condition{
+								{
+									SubjectExternalSelectorValue: ".department",
+									SubjectExternalValues:        []string{"engineering"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		Actions: []*policy.Action{
+			{
+				Name: actions.ActionNameRead,
+			},
+			{
+				Name: actions.ActionNameCreate,
+			},
+		},
+	}
+
+	departmentSalesSM = &policy.SubjectMapping{
+		SubjectConditionSet: &policy.SubjectConditionSet{
+			SubjectSets: []*policy.SubjectSet{
+				{
+					ConditionGroups: []*policy.ConditionGroup{
+						{
+							BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND,
+							Conditions: []*policy.Condition{
+								{
+									SubjectExternalSelectorValue: ".department",
+									SubjectExternalValues:        []string{"sales"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		Actions: []*policy.Action{
+			{
+				Name: actions.ActionNameRead,
+			},
+		},
+	}
+
+	groupsSM = &policy.SubjectMapping{
+		SubjectConditionSet: &policy.SubjectConditionSet{
+			SubjectSets: []*policy.SubjectSet{
+				{
+					ConditionGroups: []*policy.ConditionGroup{
+						{
+							BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_OR,
+							Conditions: []*policy.Condition{
+								{
+									SubjectExternalSelectorValue: ".groups[]",
+									SubjectExternalValues:        []string{"org1", "org2"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+								{
+									SubjectExternalSelectorValue: ".internalGroups[]",
+									SubjectExternalValues:        []string{"org3", "org4"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		Actions: []*policy.Action{
+			{
+				Name: actions.ActionNameRead,
+			},
+		},
+	}
+)
+
+// Helper function to create an entity representation for testing
+func createEntityRepresentation(originalID string, entityData map[string]interface{}) *entityresolutionV2.EntityRepresentation {
+	props, _ := structpb.NewStruct(entityData)
+	return &entityresolutionV2.EntityRepresentation{
+		OriginalId: originalID,
+		AdditionalProps: []*structpb.Struct{
+			props,
+		},
+	}
+}
+
+// Helper function to create attribute mappings
+func createAttributeMapping(attributeFQN string, subjectMappings ...*policy.SubjectMapping) *attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue {
+	value := &policy.Value{
+		SubjectMappings: subjectMappings,
+		Fqn:             attributeFQN,
+	}
+	for _, sm := range subjectMappings {
+		sm.AttributeValue = value
+	}
+	return &attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		Value: value,
+	}
+}
+
+func TestEvaluateSubjectMappingMultipleEntitiesWithActions_SingleEntity(t *testing.T) {
+	// Setup test data
+	engineeringEntity := createEntityRepresentation("engineering-entity", map[string]interface{}{
+		"department": "engineering",
+	})
+
+	attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		classConfFQN: createAttributeMapping(
+			classConfFQN,
+			departmentEngineeringSM,
+		),
+	}
+
+	result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(attributeMappings, []*entityresolutionV2.EntityRepresentation{engineeringEntity})
+	require.NoError(t, err)
+	assert.Len(t, result, 1)
+
+	// Check entity entitlements
+	entitlements, exists := result["engineering-entity"]
+	assert.True(t, exists)
+
+	// Check actions for the attribute
+	actionsList, exists := entitlements[classConfFQN]
+	assert.True(t, exists)
+	assert.Len(t, actionsList, 2)
+
+	// Verify the specific actions
+	actionNames := make([]string, 0, len(actionsList))
+	for _, action := range actionsList {
+		actionNames = append(actionNames, action.GetName())
+	}
+	assert.Contains(t, actionNames, actions.ActionNameRead)
+	assert.Contains(t, actionNames, actions.ActionNameCreate)
+}
+
+func TestEvaluateSubjectMappingMultipleEntitiesWithActions_MultipleEntities(t *testing.T) {
+	// Setup test data
+	engineeringEntity := createEntityRepresentation("engineering-entity", map[string]interface{}{
+		"department": "engineering",
+	})
+
+	salesEntity := createEntityRepresentation("sales-entity", map[string]interface{}{
+		"department": "sales",
+	})
+
+	attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		classConfFQN: createAttributeMapping(
+			classConfFQN,
+			departmentEngineeringSM,
+			departmentSalesSM,
+		),
+	}
+
+	// Execute function
+	result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(
+		attributeMappings,
+		[]*entityresolutionV2.EntityRepresentation{engineeringEntity, salesEntity},
+	)
+
+	// Validate results
+	require.NoError(t, err)
+	assert.Len(t, result, 2)
+
+	// Check engineering entity entitlements
+	engEntitlements, exists := result["engineering-entity"]
+	assert.True(t, exists)
+	engActions, exists := engEntitlements[classConfFQN]
+	assert.True(t, exists)
+	assert.Len(t, engActions, 2)
+
+	engActionNames := make([]string, 0, len(engActions))
+	for _, action := range engActions {
+		engActionNames = append(engActionNames, action.GetName())
+	}
+	assert.Contains(t, engActionNames, actions.ActionNameRead)
+	assert.Contains(t, engActionNames, actions.ActionNameCreate)
+
+	// Check sales entity entitlements
+	salesEntitlements, exists := result["sales-entity"]
+	assert.True(t, exists)
+	salesActions, exists := salesEntitlements[classConfFQN]
+	assert.True(t, exists)
+	// Sales entity should only have read access
+	assert.Len(t, salesActions, 1)
+	assert.Equal(t, actions.ActionNameRead, salesActions[0].GetName())
+}
+
+func TestEvaluateSubjectMappingMultipleEntitiesWithActions_NoMatchingEntities(t *testing.T) {
+	// Setup test data - an entity that doesn't match any subject mappings
+	marketingEntity := createEntityRepresentation("marketing-entity", map[string]interface{}{
+		"department": "marketing",
+	})
+
+	attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		classConfFQN: createAttributeMapping(
+			classConfFQN,
+			departmentEngineeringSM,
+			departmentSalesSM,
+		),
+	}
+
+	// Execute function
+	result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(
+		attributeMappings,
+		[]*entityresolutionV2.EntityRepresentation{marketingEntity},
+	)
+
+	// Validate results
+	require.NoError(t, err)
+	assert.Len(t, result, 1)
+
+	// Marketing entity should exist in the result but with empty entitlements
+	marketingEntitlements, exists := result["marketing-entity"]
+	assert.True(t, exists)
+	assert.Empty(t, marketingEntitlements)
+}
+
+func TestEvaluateSubjectMappingMultipleEntitiesWithActions_MultipleAttributes(t *testing.T) {
+	// Setup test data with both department and group membership
+	engineeringEntity := createEntityRepresentation("engineering-entity", map[string]interface{}{
+		"department": "engineering",
+		"groups":     []any{"org1", "org5"},
+	})
+
+	attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		classConfFQN: createAttributeMapping(
+			classConfFQN,
+			departmentEngineeringSM,
+		),
+		classIntFQN: createAttributeMapping(
+			classIntFQN,
+			groupsSM,
+		),
+	}
+
+	// Execute function
+	result, err := subjectmappingbuiltin.EvaluateSubjectMappingMultipleEntitiesWithActions(
+		attributeMappings,
+		[]*entityresolutionV2.EntityRepresentation{engineeringEntity},
+	)
+
+	// Validate results
+	require.NoError(t, err)
+	assert.Len(t, result, 1)
+
+	entitlements, exists := result["engineering-entity"]
+	assert.True(t, exists)
+	assert.Len(t, entitlements, 2)
+
+	// Check department-based entitlements (Confidential)
+	confActions, exists := entitlements[classConfFQN]
+	assert.True(t, exists)
+	assert.NotEmpty(t, confActions)
+	assert.Len(t, confActions, 2)
+	actionNames := make([]string, 0)
+	for _, action := range confActions {
+		actionNames = append(actionNames, action.GetName())
+	}
+	assert.Contains(t, actionNames, actions.ActionNameRead)
+	assert.Contains(t, actionNames, actions.ActionNameCreate)
+
+	// Check group-based entitlements (Internal)
+	internalActions, exists := entitlements[classIntFQN]
+	assert.True(t, exists)
+	assert.NotEmpty(t, internalActions)
+	assert.Len(t, internalActions, 1)
+	assert.Equal(t, actions.ActionNameRead, internalActions[0].GetName())
+}
+
+func TestEvaluateSubjectMappingsWithActions_OneGoodResolution(t *testing.T) {
+	tests := []struct {
+		name           string
+		entityID       string
+		attrFQN        string
+		entity         map[string]interface{}
+		subjectMapping *policy.SubjectMapping
+	}{
+		{
+			name:     "Engineering Department",
+			entityID: "engineering-entity",
+			attrFQN:  classConfFQN,
+			entity: map[string]interface{}{
+				"department": "engineering",
+			},
+			subjectMapping: departmentEngineeringSM,
+		},
+		{
+			name:     "Sales Department",
+			entityID: "sales-entity",
+			attrFQN:  classConfFQN,
+			entity: map[string]interface{}{
+				"department": "sales",
+			},
+			subjectMapping: departmentSalesSM,
+		},
+		{
+			name:     "Group Membership",
+			entityID: "org-member",
+			attrFQN:  classIntFQN,
+			entity: map[string]interface{}{
+				"groups": []any{"org1", "org5"},
+			},
+			subjectMapping: groupsSM,
+		},
+		{
+			name:     "Internal Group Membership",
+			entityID: "internal-org-member",
+			attrFQN:  classIntFQN,
+			entity: map[string]interface{}{
+				"internalGroups": []any{"org3", "org6"},
+			},
+			subjectMapping: groupsSM,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Setup test data
+			entity := createEntityRepresentation(tt.entityID, tt.entity)
+
+			attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+				tt.attrFQN: createAttributeMapping(
+					tt.attrFQN,
+					tt.subjectMapping,
+				),
+			}
+			// Execute function
+			entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, entity)
+			require.NoError(t, err)
+			assert.Len(t, entitlements, 1)
+
+			// Check actions for the attribute
+			actionsList, exists := entitlements[tt.attrFQN]
+			assert.True(t, exists)
+			assert.Len(t, actionsList, len(tt.subjectMapping.GetActions()))
+			actionNames := make([]string, 0, len(actionsList))
+			for _, action := range actionsList {
+				actionNames = append(actionNames, action.GetName())
+			}
+			for _, expectedAction := range tt.subjectMapping.GetActions() {
+				assert.Contains(t, actionNames, expectedAction.GetName())
+			}
+		})
+	}
+}
+
+func TestEvaluateSubjectMappingsWithActions_MultipleMatchingSubjectMappings(t *testing.T) {
+	// Setup test data with entity that matches both department and group conditions
+	multiMatchEntity := createEntityRepresentation("multi-match-entity", map[string]interface{}{
+		"department":     "engineering",
+		"groups":         []any{"org1"},
+		"internalGroups": []any{"org3"},
+	})
+
+	customActionSM := &policy.SubjectMapping{
+		SubjectConditionSet: &policy.SubjectConditionSet{
+			SubjectSets: []*policy.SubjectSet{
+				{
+					ConditionGroups: []*policy.ConditionGroup{
+						{
+							BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_OR,
+							Conditions: []*policy.Condition{
+								{
+									SubjectExternalSelectorValue: ".internalGroups[]",
+									SubjectExternalValues:        []string{"org3"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		Actions: []*policy.Action{
+			{
+				Name: "custom_action",
+			},
+		},
+	}
+
+	attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		classConfFQN: createAttributeMapping(
+			classConfFQN,
+			departmentEngineeringSM,
+			customActionSM,
+		),
+		classIntFQN: createAttributeMapping(
+			classIntFQN,
+			groupsSM,
+		),
+	}
+
+	// Execute function
+	entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, multiMatchEntity)
+
+	// Validate results
+	require.NoError(t, err)
+	assert.Len(t, entitlements, 2)
+
+	// Check confidential actions - should include read, create, and update from both subject mappings
+	confActions, exists := entitlements[classConfFQN]
+	assert.True(t, exists)
+
+	// Count unique actions (should be 3: read, create, update)
+	actionNameSet := make(map[string]bool)
+	for _, action := range confActions {
+		actionNameSet[action.GetName()] = true
+	}
+	assert.Len(t, actionNameSet, 3)
+	assert.True(t, actionNameSet[actions.ActionNameRead])
+	assert.True(t, actionNameSet[actions.ActionNameCreate])
+	assert.True(t, actionNameSet["custom_action"])
+
+	// Check internal actions
+	internalActions, exists := entitlements[classIntFQN]
+	assert.True(t, exists)
+
+	assert.Len(t, internalActions, 1)
+	assert.Equal(t, actions.ActionNameRead, internalActions[0].GetName())
+}
+
+func TestEvaluateSubjectMappingsWithActions_NoMatchingSubjectMappings(t *testing.T) {
+	// Setup test data with entity that doesn't match any subject mappings
+	marketingEntity := createEntityRepresentation("marketing-entity", map[string]interface{}{
+		"department": "marketing",
+		"groups":     []any{"org7"},
+	})
+
+	attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		classConfFQN: createAttributeMapping(
+			classConfFQN,
+			departmentEngineeringSM,
+			departmentSalesSM,
+		),
+		classIntFQN: createAttributeMapping(
+			classIntFQN,
+			groupsSM,
+		),
+	}
+
+	// Execute function
+	entitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, marketingEntity)
+
+	// Validate results
+	require.NoError(t, err)
+	assert.Empty(t, entitlements)
+}
+
+func TestEvaluateSubjectMappingsWithActions_ComplexCondition_MultipleConditionGroupsAreAND(t *testing.T) {
+	// Create a subject mapping with complex conditions (AND + OR)
+	complexSM := &policy.SubjectMapping{
+		SubjectConditionSet: &policy.SubjectConditionSet{
+			SubjectSets: []*policy.SubjectSet{
+				{
+					ConditionGroups: []*policy.ConditionGroup{
+						{
+							BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND,
+							Conditions: []*policy.Condition{
+								{
+									SubjectExternalSelectorValue: ".department",
+									SubjectExternalValues:        []string{"engineering"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+								{
+									SubjectExternalSelectorValue: ".level",
+									SubjectExternalValues:        []string{"senior", "principal"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+							},
+						},
+						{
+							BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_OR,
+							Conditions: []*policy.Condition{
+								{
+									SubjectExternalSelectorValue: ".roles[0]",
+									SubjectExternalValues:        []string{"admin"},
+									Operator:                     policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		Actions: []*policy.Action{
+			{
+				Name: actions.ActionNameRead,
+			},
+			{
+				Name: actions.ActionNameDelete,
+			},
+		},
+	}
+
+	// Setup test data - non-matching senior engineer
+	seniorEngEntity := createEntityRepresentation("senior-engineer", map[string]interface{}{
+		"department": "engineering",
+		"level":      "senior",
+	})
+
+	// Setup test data - matching principal engineer with admin attribute
+	principalEngWithAdmin := createEntityRepresentation("engineer-with-admin", map[string]interface{}{
+		"roles":      []any{"admin"},
+		"department": "engineering",
+		"level":      "principal",
+	})
+
+	// Setup test data - matching senior engineer with admin attribute in index other than selected
+	seniorEngWithAdminEntityInBadIndex := createEntityRepresentation("engineer-with-wrong-admin", map[string]interface{}{
+		"roles":      []any{"user", "admin"}, // selector looks for admin in index 0
+		"department": "engineering",
+		"level":      "senior",
+	})
+
+	// Setup test data - non-matching admin user
+	nonEngAdminEntity := createEntityRepresentation("admin-user", map[string]interface{}{
+		"roles": []any{"admin"},
+	})
+
+	attributeMappings := map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{
+		classRestrictedFQN: createAttributeMapping(
+			classRestrictedFQN,
+			complexSM,
+		),
+	}
+
+	// Test senior engineer
+	seniorEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, seniorEngEntity)
+	require.NoError(t, err)
+	assert.Empty(t, seniorEntitlements)
+	seniorActions, exists := seniorEntitlements[classRestrictedFQN]
+	assert.False(t, exists)
+	assert.Empty(t, seniorActions)
+
+	// Test principal engineer with admin
+	adminEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, principalEngWithAdmin)
+	require.NoError(t, err)
+	assert.Len(t, adminEntitlements, 1)
+	seniorWithAdminActions, exists := adminEntitlements[classRestrictedFQN]
+	assert.True(t, exists)
+	assert.Len(t, seniorWithAdminActions, 2)
+	actionNames := make([]string, 0, len(seniorWithAdminActions))
+	for _, action := range seniorWithAdminActions {
+		actionNames = append(actionNames, action.GetName())
+	}
+	assert.Contains(t, actionNames, actions.ActionNameRead)
+	assert.Contains(t, actionNames, actions.ActionNameDelete)
+
+	// Test senior engineer with admin in a different index
+	adminEntitlementsBadIndex, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, seniorEngWithAdminEntityInBadIndex)
+	require.NoError(t, err)
+	assert.Empty(t, adminEntitlementsBadIndex)
+	adminActionsBadIndex, exists := adminEntitlementsBadIndex[classRestrictedFQN]
+	assert.False(t, exists)
+	assert.Empty(t, adminActionsBadIndex)
+
+	// Test non-engineering admin
+	nonEngAdminEntitlements, err := subjectmappingbuiltin.EvaluateSubjectMappingsWithActions(attributeMappings, nonEngAdminEntity)
+	require.NoError(t, err)
+	assert.Empty(t, nonEngAdminEntitlements)
+	nonEngAdminActions, exists := nonEngAdminEntitlements[classRestrictedFQN]
+	assert.False(t, exists)
+	assert.Empty(t, nonEngAdminActions)
+}


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR enhances the subject mapping plugin to support actions as part of the subject mapping evaluation for ABAC authorization. Key changes include the introduction of EvaluateSubjectMappingMultipleEntitiesWithActions and EvaluateSubjectMappingsWithActions functions, which deduplicate actions and aggregate entitlements based on subject mapping evaluation.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T04:07:12Z
user: login: pflynn-virtru
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-16T18:13:58Z
user: login: jentfoo
body: LGTM, feel free to ignore my minor suggestions
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T18:47:42Z
user: login: jakedoublev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T23:30:29Z
user: login: jentfoo
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T14:44:40Z
user: login: pflynn-virtru
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T14:57:19Z

Review comments:
diff_hunk: @@ -0,0 +1,87 @@
+package subjectmappingbuiltin
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/opentdf/platform/lib/flattening"
+	entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2"
+	"github.com/opentdf/platform/protocol/go/policy"
+	"github.com/opentdf/platform/protocol/go/policy/attributes"
+)
+
+type AttributeValueFQNsToActions map[string][]*policy.Action
+
+type EntityIDsToEntitlements map[string]AttributeValueFQNsToActions
+
+func EvaluateSubjectMappingMultipleEntitiesWithActions(
+	attributeMappings map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue,
+	entityRepresentations []*entityresolutionV2.EntityRepresentation,
+) (EntityIDsToEntitlements, error) {
+	results := make(map[string]AttributeValueFQNsToActions)
path: service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go
user: login: jentfoo
body: ```suggestion
	results := make(map[string]AttributeValueFQNsToActions, len(entityRepresentations))
```

minor optimization if you're interested
created_at: 2025-05-16T18:35:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+package subjectmappingbuiltin
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/opentdf/platform/lib/flattening"
+	entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2"
+	"github.com/opentdf/platform/protocol/go/policy"
+	"github.com/opentdf/platform/protocol/go/policy/attributes"
+)
+
+type AttributeValueFQNsToActions map[string][]*policy.Action
+
+type EntityIDsToEntitlements map[string]AttributeValueFQNsToActions
+
+func EvaluateSubjectMappingMultipleEntitiesWithActions(
+	attributeMappings map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue,
+	entityRepresentations []*entityresolutionV2.EntityRepresentation,
+) (EntityIDsToEntitlements, error) {
+	results := make(map[string]AttributeValueFQNsToActions)
+	for _, er := range entityRepresentations {
+		entitlements, err := EvaluateSubjectMappingsWithActions(attributeMappings, er)
+		if err != nil {
+			return nil, err
+		}
+		results[er.GetOriginalId()] = entitlements
+	}
+
+	return results, nil
+}
+
+// Returns a map of attribute value FQNs to each entitled action on the value
+func EvaluateSubjectMappingsWithActions(
+	resolveableAttributes map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue,
+	entityRepresentation *entityresolutionV2.EntityRepresentation,
+) (AttributeValueFQNsToActions, error) {
+	jsonEntities := entityRepresentation.GetAdditionalProps()
+	entitlementsSet := make(map[string][]*policy.Action)
+
+	for _, entity := range jsonEntities {
+		flattenedEntity, err := flattening.Flatten(entity.AsMap())
+		if err != nil {
+			return nil, fmt.Errorf("failure to flatten entity in subject mapping builtin: %w", err)
+		}
+
+		for valueFQN, attributeAndValue := range resolveableAttributes {
+			// subject mapping results or-ed together
+			for _, subjectMapping := range attributeAndValue.GetValue().GetSubjectMappings() {
+				subjectMappingResult := true
+				for _, subjectSet := range subjectMapping.GetSubjectConditionSet().GetSubjectSets() {
+					subjectSetConditionResult, err := EvaluateSubjectSet(subjectSet, flattenedEntity)
+					if err != nil {
+						return nil, err
+					}
+					// update the result for the subject mapping
+					subjectMappingResult = subjectMappingResult && subjectSetConditionResult
+					// if one subject condition set fails, subject mapping fails
+					if !subjectSetConditionResult {
+						break
+					}
+				}
+
+				// each subject mapping that is true should permit the actions on the mapped value
+				if subjectMappingResult {
+					// add value FQN to the entitlements set
+					if _, ok := entitlementsSet[valueFQN]; !ok {
+						entitlementsSet[valueFQN] = make([]*policy.Action, 0)
path: service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go
user: login: jentfoo
body: ```suggestion
						entitlementsSet[valueFQN] = make([]*policy.Action, 0, len(actions))
```

This might slightly over-allocate, but would avoid reallocations.

Alternatively this would be more performant (technically more temporary memory allocations, but overall faster and less retained memory) and less code by building a map like the following:
```
m := make(map[string]string, len(actions))
for _, action := range actions {
    m[strings.ToUpper(action.GetName())] = action.GetName()
}
entitlementsSet[valueFQN] = slices.Collect(maps.Values(m))
```

Technically last witnessed value wins in the above, where your implementation prefers only the first EqualFold.  But it seems like a minor detail between the two implementations.

**edit - Also technically my implementation may change the order, but that also does not seem like an important detail?
created_at: 2025-05-16T18:46:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+package subjectmappingbuiltin
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/opentdf/platform/lib/flattening"
+	entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2"
+	"github.com/opentdf/platform/protocol/go/policy"
+	"github.com/opentdf/platform/protocol/go/policy/attributes"
+)
+
+type AttributeValueFQNsToActions map[string][]*policy.Action
+
+type EntityIDsToEntitlements map[string]AttributeValueFQNsToActions
+
+func EvaluateSubjectMappingMultipleEntitiesWithActions(
+	attributeMappings map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue,
+	entityRepresentations []*entityresolutionV2.EntityRepresentation,
+) (EntityIDsToEntitlements, error) {
+	results := make(map[string]AttributeValueFQNsToActions)
+	for _, er := range entityRepresentations {
+		entitlements, err := EvaluateSubjectMappingsWithActions(attributeMappings, er)
+		if err != nil {
+			return nil, err
+		}
+		results[er.GetOriginalId()] = entitlements
+	}
+
+	return results, nil
+}
+
+// Returns a map of attribute value FQNs to each entitled action on the value
+func EvaluateSubjectMappingsWithActions(
+	resolveableAttributes map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue,
+	entityRepresentation *entityresolutionV2.EntityRepresentation,
+) (AttributeValueFQNsToActions, error) {
+	jsonEntities := entityRepresentation.GetAdditionalProps()
+	entitlementsSet := make(map[string][]*policy.Action)
+
+	for _, entity := range jsonEntities {
+		flattenedEntity, err := flattening.Flatten(entity.AsMap())
+		if err != nil {
+			return nil, fmt.Errorf("failure to flatten entity in subject mapping builtin: %w", err)
+		}
+
+		for valueFQN, attributeAndValue := range resolveableAttributes {
+			// subject mapping results or-ed together
+			for _, subjectMapping := range attributeAndValue.GetValue().GetSubjectMappings() {
+				subjectMappingResult := true
+				for _, subjectSet := range subjectMapping.GetSubjectConditionSet().GetSubjectSets() {
+					subjectSetConditionResult, err := EvaluateSubjectSet(subjectSet, flattenedEntity)
+					if err != nil {
+						return nil, err
+					}
+					// update the result for the subject mapping
+					subjectMappingResult = subjectMappingResult && subjectSetConditionResult
+					// if one subject condition set fails, subject mapping fails
+					if !subjectSetConditionResult {
+						break
+					}
+				}
+
+				// each subject mapping that is true should permit the actions on the mapped value
+				if subjectMappingResult {
+					// add value FQN to the entitlements set
+					if _, ok := entitlementsSet[valueFQN]; !ok {
+						entitlementsSet[valueFQN] = make([]*policy.Action, 0)
path: service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go
user: login: jakedoublev
body: Correct. Order is not of importance, so this is a good optimization. Pushing up.
created_at: 2025-05-17T23:30:29Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-16T04:09:06Z
body: <details><summary>Benchmark results, click to expand</summary>

# Benchmark Results:
| Metric                  | Value                  |
|-------------------------|------------------------|
| Approved Decision Requests | 5000 |
| Denied Decision Requests   | 0 |
| Total Time              | 459.549777ms |

## Bulk Benchmark Results
| Metric               | Value                     |
|----------------------|---------------------------|
| Total Decrypts       | 100                        |
| Successful Decrypts  | 100                        |
| Failed Decrypts      | 0                        |
| Total Time           | 367.916762ms                        |
| Throughput           | 271.80 requests/second      |

## TDF3 Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 5000                        |
| Failed Requests       | 0                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m17.843375734s                        |
| Average Latency       | 775.828821ms                        |
| Throughput            | 64.23 requests/second      |

## NANOTDF Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 4945                        |
| Failed Requests       | 55                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m9.077321099s                        |
| Average Latency       | 688.022693ms                        |
| Throughput            | 71.59 requests/second      |

### Error Summary:
| Error Message         | Occurrences              |
|-----------------------|---------------------------|
| ReadNanoTDF error: getNanoRewrapKey: rewrapError: internal: internal error<br>rpc error: code = Internal desc = could not perform access | 55 occurrences         |

## Standard Benchmark Metrics Skipped or Failed</details>
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-16T14:50:40Z
body: <details><summary>Benchmark results, click to expand</summary>

# Benchmark Results:
| Metric                  | Value                  |
|-------------------------|------------------------|
| Approved Decision Requests | 5000 |
| Denied Decision Requests   | 0 |
| Total Time              | 454.704135ms |

## Bulk Benchmark Results
| Metric               | Value                     |
|----------------------|---------------------------|
| Total Decrypts       | 100                        |
| Successful Decrypts  | 100                        |
| Failed Decrypts      | 0                        |
| Total Time           | 369.573174ms                        |
| Throughput           | 270.58 requests/second      |

## TDF3 Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 5000                        |
| Failed Requests       | 0                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m16.343297673s                        |
| Average Latency       | 761.352599ms                        |
| Throughput            | 65.49 requests/second      |

## NANOTDF Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 4934                        |
| Failed Requests       | 66                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m6.709931084s                        |
| Average Latency       | 665.174711ms                        |
| Throughput            | 73.96 requests/second      |

### Error Summary:
| Error Message         | Occurrences              |
|-----------------------|---------------------------|
| ReadNanoTDF error: getNanoRewrapKey: rewrapError: internal: internal error<br>rpc error: code = Internal desc = could not perform access | 66 occurrences         |

## Standard Benchmark Metrics Skipped or Failed</details>
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-17T23:36:07Z
body: <details><summary>Benchmark results, click to expand</summary>

# Benchmark Results:
| Metric                  | Value                  |
|-------------------------|------------------------|
| Approved Decision Requests | 5000 |
| Denied Decision Requests   | 0 |
| Total Time              | 482.481082ms |

## Bulk Benchmark Results
| Metric               | Value                     |
|----------------------|---------------------------|
| Total Decrypts       | 100                        |
| Successful Decrypts  | 100                        |
| Failed Decrypts      | 0                        |
| Total Time           | 373.119087ms                        |
| Throughput           | 268.01 requests/second      |

## TDF3 Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 5000                        |
| Failed Requests       | 0                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m18.53804271s                        |
| Average Latency       | 783.67599ms                        |
| Throughput            | 63.66 requests/second      |

## NANOTDF Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 4941                        |
| Failed Requests       | 59                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m8.467987849s                        |
| Average Latency       | 682.106898ms                        |
| Throughput            | 72.17 requests/second      |

### Error Summary:
| Error Message         | Occurrences              |
|-----------------------|---------------------------|
| ReadNanoTDF error: getNanoRewrapKey: rewrapError: internal: internal error<br>rpc error: code = Internal desc = could not perform access | 59 occurrences         |

## Standard Benchmark Metrics Skipped or Failed</details>
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-19T14:35:18Z
body: <details><summary>Benchmark results, click to expand</summary>

# Benchmark Results:
| Metric                  | Value                  |
|-------------------------|------------------------|
| Approved Decision Requests | 5000 |
| Denied Decision Requests   | 0 |
| Total Time              | 460.684887ms |

## Bulk Benchmark Results
| Metric               | Value                     |
|----------------------|---------------------------|
| Total Decrypts       | 100                        |
| Successful Decrypts  | 100                        |
| Failed Decrypts      | 0                        |
| Total Time           | 364.656535ms                        |
| Throughput           | 274.23 requests/second      |

## TDF3 Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 5000                        |
| Failed Requests       | 0                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m17.18549883s                        |
| Average Latency       | 769.356167ms                        |
| Throughput            | 64.78 requests/second      |

## NANOTDF Benchmark Results:
| Metric                | Value                     |
|-----------------------|---------------------------|
| Total Requests        | 5000                        |
| Successful Requests   | 4947                        |
| Failed Requests       | 53                        |
| Concurrent Requests   | 50                        |
| Total Time            | 1m7.663661475s                        |
| Average Latency       | 673.180037ms                        |
| Throughput            | 73.11 requests/second      |

### Error Summary:
| Error Message         | Occurrences              |
|-----------------------|---------------------------|
| ReadNanoTDF error: getNanoRewrapKey: rewrapError: internal: internal error<br>rpc error: code = Internal desc = could not perform access | 53 occurrences         |

## Standard Benchmark Metrics Skipped or Failed</details>
author_association: CONTRIBUTOR
user: login: ryanulit
created_at: 2025-05-19T15:27:15Z
body: 👍 
author_association: CONTRIBUTOR