From 23611b9771b4cb8cb0d89b226b2d044d9eafe457 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Thu, 18 Jun 2026 15:18:31 -0400 Subject: [PATCH 1/5] feat(policy): DSPX-2754 dynamic attribute value entitlement mappings Service consumer for dynamic value mappings, built on protocol/go v0.34.0 and sdk v0.23.0: - DB: dynamic_value_mappings table + queries + CRUD, no-coexistence guard with value-level subject mappings, comparison + case_insensitive columns. - Service: DynamicValueMappingService, validators, and PDP wiring that loads dynamic mappings via the SDK and evaluates them alongside subject mappings. - Evaluation: shared comparison helper (EQUALS/CONTAINS/STARTS_WITH/ENDS_WITH) used by both static conditions (with quantifier + deprecated-operator normalization) and the dynamic resolver (existential, case_insensitive). protocol/go protos, the dynamicvaluemapping package, and the sdk wrapper landed in #3580 and #3635; this PR bumps to those releases. Signed-off-by: Krish Suchak --- service/authorization/v2/cache.go | 50 +- .../dynamic_value_mappings_test.go | 236 +++++++ service/internal/access/v2/helpers.go | 29 +- service/internal/access/v2/helpers_test.go | 3 + .../internal/access/v2/just_in_time_pdp.go | 6 +- service/internal/access/v2/pdp.go | 96 ++- .../internal/access/v2/pdp_dynamic_test.go | 86 +++ service/internal/access/v2/policy_store.go | 37 +- service/internal/access/v2/validators.go | 34 + .../dynamic_value_mapping_builtin.go | 135 ++++ .../dynamic_value_mapping_builtin_test.go | 181 +++++ .../subject_mapping_builtin.go | 148 +++-- service/logger/audit/constants.go | 2 + ...amic-attribute-value-entitlements-spike.md | 140 ++++ service/policy/db/attributes.go | 14 + service/policy/db/dynamic_value_mappings.go | 405 ++++++++++++ .../policy/db/dynamic_value_mappings.sql.go | 624 ++++++++++++++++++ ...60618000000_add_dynamic_value_mappings.sql | 63 ++ service/policy/db/models.go | 22 + .../db/queries/dynamic_value_mappings.sql | 220 ++++++ service/policy/db/subject_mappings.go | 7 + service/policy/db/utils.go | 21 + .../dynamic_value_mapping.go | 189 ++++++ service/policy/policy.go | 2 + 24 files changed, 2673 insertions(+), 77 deletions(-) create mode 100644 service/integration/dynamic_value_mappings_test.go create mode 100644 service/internal/access/v2/pdp_dynamic_test.go create mode 100644 service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go create mode 100644 service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go create mode 100644 service/policy/adr/0005-dynamic-attribute-value-entitlements-spike.md create mode 100644 service/policy/db/dynamic_value_mappings.go create mode 100644 service/policy/db/dynamic_value_mappings.sql.go create mode 100644 service/policy/db/migrations/20260618000000_add_dynamic_value_mappings.sql create mode 100644 service/policy/db/queries/dynamic_value_mappings.sql create mode 100644 service/policy/dynamicvaluemapping/dynamic_value_mapping.go diff --git a/service/authorization/v2/cache.go b/service/authorization/v2/cache.go index 57db585676..1fd9acb767 100644 --- a/service/authorization/v2/cache.go +++ b/service/authorization/v2/cache.go @@ -14,10 +14,11 @@ import ( ) const ( - attributesCacheKey = "attributes_cache_key" - subjectMappingsCacheKey = "subject_mappings_cache_key" - registeredResourcesCacheKey = "registered_resources_cache_key" - obligationsCacheKey = "obligations_cache_key" + attributesCacheKey = "attributes_cache_key" + subjectMappingsCacheKey = "subject_mappings_cache_key" + dynamicValueMappingsCacheKey = "dynamic_value_mappings_cache_key" + registeredResourcesCacheKey = "registered_resources_cache_key" + obligationsCacheKey = "obligations_cache_key" ) var ( @@ -60,10 +61,11 @@ type EntitlementPolicyCache struct { // The EntitlementPolicy struct holds all the cached entitlement policy, as generics allow one // data type per service cache instance. type EntitlementPolicy struct { - Attributes []*policy.Attribute - SubjectMappings []*policy.SubjectMapping - RegisteredResources []*policy.RegisteredResource - Obligations []*policy.Obligation + Attributes []*policy.Attribute + SubjectMappings []*policy.SubjectMapping + DynamicValueMappings []*policy.DynamicValueMapping + RegisteredResources []*policy.RegisteredResource + Obligations []*policy.Obligation } // NewEntitlementPolicyCache holds a platform-provided cache client and manages a periodic refresh of @@ -178,6 +180,10 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { if err != nil { return err } + dynamicValueMappings, err := c.retriever.ListAllDynamicValueMappings(ctx) + if err != nil { + return err + } registeredResources, err := c.retriever.ListAllRegisteredResources(ctx) if err != nil { return err @@ -200,6 +206,12 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { return errors.Join(ErrFailedToSet, err) } + err = c.cacheClient.Set(ctx, dynamicValueMappingsCacheKey, dynamicValueMappings, authzCacheTags) + if err != nil { + c.isCacheFilled = false + return errors.Join(ErrFailedToSet, err) + } + err = c.cacheClient.Set(ctx, registeredResourcesCacheKey, registeredResources, authzCacheTags) if err != nil { c.isCacheFilled = false @@ -270,6 +282,28 @@ func (c *EntitlementPolicyCache) ListAllSubjectMappings(ctx context.Context) ([] return subjectMappings, nil } +// ListAllDynamicValueMappings returns the cached dynamic value entitlement mappings, or none on a cache miss +func (c *EntitlementPolicyCache) ListAllDynamicValueMappings(ctx context.Context) ([]*policy.DynamicValueMapping, error) { + var ( + mappings []*policy.DynamicValueMapping + ok bool + ) + + cached, err := c.cacheClient.Get(ctx, dynamicValueMappingsCacheKey) + if err != nil { + if errors.Is(err, cache.ErrCacheMiss) { + return mappings, nil + } + return nil, fmt.Errorf("%w, dynamic value mappings: %w", ErrFailedToGet, err) + } + + mappings, ok = cached.([]*policy.DynamicValueMapping) + if !ok { + return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, cached) + } + return mappings, nil +} + // ListAllRegisteredResources returns the cached registered resources, or none in the event of a cache miss func (c *EntitlementPolicyCache) ListAllRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) { var ( diff --git a/service/integration/dynamic_value_mappings_test.go b/service/integration/dynamic_value_mappings_test.go new file mode 100644 index 0000000000..cf06d13f5f --- /dev/null +++ b/service/integration/dynamic_value_mappings_test.go @@ -0,0 +1,236 @@ +package integration + +import ( + "context" + "log/slog" + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/dynamicvaluemapping" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" + "github.com/opentdf/platform/protocol/go/policy/unsafe" + "github.com/opentdf/platform/service/internal/fixtures" + policydb "github.com/opentdf/platform/service/policy/db" + "github.com/stretchr/testify/suite" +) + +type DynamicValueMappingsSuite struct { + suite.Suite + f fixtures.Fixtures + db fixtures.DBInterface + //nolint:containedctx // Only used for test suite + ctx context.Context +} + +func (s *DynamicValueMappingsSuite) SetupSuite() { + slog.Info("setting up db.DynamicValueMappings test suite") + s.ctx = context.Background() + c := *Config + c.DB.Schema = "test_opentdf_dynamic_value_mappings" + s.db = fixtures.NewDBInterface(s.ctx, c) + s.f = fixtures.NewFixture(s.db) + s.f.Provision(s.ctx) +} + +func (s *DynamicValueMappingsSuite) TearDownSuite() { + slog.Info("tearing down db.DynamicValueMappings test suite") + s.f.TearDown(s.ctx) +} + +func TestDynamicValueMappingsSuite(t *testing.T) { + if testing.Short() { + t.Skip("skipping dynamic_value_mappings integration tests") + } + suite.Run(t, new(DynamicValueMappingsSuite)) +} + +func (s *DynamicValueMappingsSuite) TestCreateAndGet() { + attr := s.createDefinition("dvem_create_ok", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + + created, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().NoError(err) + s.Require().NotEmpty(created.GetId()) + + got, err := s.db.PolicyClient.GetDynamicValueMapping(s.ctx, created.GetId()) + s.Require().NoError(err) + s.Equal(attr.GetId(), got.GetAttributeDefinition().GetId()) + s.Equal(".patientAssignments[]", got.GetValueResolver().GetSubjectExternalSelectorValue()) + s.Equal(policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, got.GetValueResolver().GetComparison()) + s.Len(got.GetActions(), 1) + s.Nil(got.GetSubjectConditionSet(), "optional static pre-gate omitted") +} + +func (s *DynamicValueMappingsSuite) TestCreateWithStaticGate() { + attr := s.createDefinition("dvem_create_gate", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + + created, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + NewSubjectConditionSet: s.sampleSCSCreate(), + }) + s.Require().NoError(err) + + got, err := s.db.PolicyClient.GetDynamicValueMapping(s.ctx, created.GetId()) + s.Require().NoError(err) + s.Require().NotNil(got.GetSubjectConditionSet(), "static pre-gate should be hydrated") + s.NotEmpty(got.GetSubjectConditionSet().GetSubjectSets()) +} + +func (s *DynamicValueMappingsSuite) TestRejectsHierarchyDefinition() { + attr := s.createDefinition("dvem_hierarchy", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY) + + _, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().Error(err, "HIERARCHY definitions must be rejected") +} + +func (s *DynamicValueMappingsSuite) TestNoCoexistence_SubjectMappingThenDynamic() { + attr := s.createDefinition("dvem_coexist_fwd", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + val, err := s.db.PolicyClient.CreateAttributeValue(s.ctx, attr.GetId(), &attributes.CreateAttributeValueRequest{Value: "v1"}) + s.Require().NoError(err) + + _, err = s.db.PolicyClient.CreateSubjectMapping(s.ctx, &subjectmapping.CreateSubjectMappingRequest{ + AttributeValueId: val.GetId(), + Actions: []*policy.Action{s.readAction()}, + NewSubjectConditionSet: s.sampleSCSCreate(), + }) + s.Require().NoError(err) + + // definition now has a value-level subject mapping; a dynamic mapping must be rejected + _, err = s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().Error(err, "dynamic mapping must not coexist with value-level subject mappings") +} + +func (s *DynamicValueMappingsSuite) TestNoCoexistence_DynamicThenSubjectMapping() { + attr := s.createDefinition("dvem_coexist_rev", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + + _, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().NoError(err) + + val, err := s.db.PolicyClient.CreateAttributeValue(s.ctx, attr.GetId(), &attributes.CreateAttributeValueRequest{Value: "v1"}) + s.Require().NoError(err) + + // definition now has a dynamic mapping; a value-level subject mapping must be rejected + _, err = s.db.PolicyClient.CreateSubjectMapping(s.ctx, &subjectmapping.CreateSubjectMappingRequest{ + AttributeValueId: val.GetId(), + Actions: []*policy.Action{s.readAction()}, + NewSubjectConditionSet: s.sampleSCSCreate(), + }) + s.Require().Error(err, "value-level subject mapping must not coexist with a dynamic mapping") +} + +func (s *DynamicValueMappingsSuite) TestRejectsRuleChangeToHierarchy() { + attr := s.createDefinition("dvem_rule_guard", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + + _, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().NoError(err) + + _, err = s.db.PolicyClient.UnsafeUpdateAttribute(s.ctx, &unsafe.UnsafeUpdateAttributeRequest{ + Id: attr.GetId(), + Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, + }) + s.Require().Error(err, "changing the rule to HIERARCHY must be rejected when a dynamic mapping exists") +} + +func (s *DynamicValueMappingsSuite) TestUpdateAndDelete() { + attr := s.createDefinition("dvem_update_delete", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF) + + created, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().NoError(err) + + updated, err := s.db.PolicyClient.UpdateDynamicValueMapping(s.ctx, &dynamicvaluemapping.UpdateDynamicValueMappingRequest{ + Id: created.GetId(), + ValueResolver: s.resolver(".accounts[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_CONTAINS), + }) + s.Require().NoError(err) + s.Equal(".accounts[]", updated.GetValueResolver().GetSubjectExternalSelectorValue()) + s.Equal(policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_CONTAINS, updated.GetValueResolver().GetComparison()) + + _, err = s.db.PolicyClient.DeleteDynamicValueMapping(s.ctx, created.GetId()) + s.Require().NoError(err) + + _, err = s.db.PolicyClient.GetDynamicValueMapping(s.ctx, created.GetId()) + s.Require().Error(err, "mapping should be gone after delete") +} + +func (s *DynamicValueMappingsSuite) TestListByDefinition() { + attr := s.createDefinition("dvem_list", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + _, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().NoError(err) + + resp, err := s.db.PolicyClient.ListDynamicValueMappings(s.ctx, &dynamicvaluemapping.ListDynamicValueMappingsRequest{ + AttributeDefinitionId: attr.GetId(), + }) + s.Require().NoError(err) + s.Require().Len(resp.GetDynamicValueMappings(), 1) + s.Equal(attr.GetId(), resp.GetDynamicValueMappings()[0].GetAttributeDefinition().GetId()) +} + +// createDefinition makes a fresh attribute under the example.com namespace with no values +// or subject mappings, so each test controls its own coexistence state. +func (s *DynamicValueMappingsSuite) createDefinition(name string, rule policy.AttributeRuleTypeEnum) *policy.Attribute { + nsID := s.f.GetNamespaceKey("example.com").ID + attr, err := s.db.PolicyClient.CreateAttribute(s.ctx, &attributes.CreateAttributeRequest{ + Name: name, + NamespaceId: nsID, + Rule: rule, + }) + s.Require().NoError(err) + s.Require().NotNil(attr) + return attr +} + +func (s *DynamicValueMappingsSuite) readAction() *policy.Action { + return s.f.GetStandardAction(policydb.ActionRead.String()) +} + +func (s *DynamicValueMappingsSuite) resolver(selector string, comparison policy.ConditionComparisonOperatorEnum) *policy.DynamicValueResolver { + return &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: selector, + Comparison: comparison, + } +} + +func (s *DynamicValueMappingsSuite) sampleSCSCreate() *subjectmapping.SubjectConditionSetCreate { + return &subjectmapping.SubjectConditionSetCreate{ + SubjectSets: []*policy.SubjectSet{{ + ConditionGroups: []*policy.ConditionGroup{{ + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + Conditions: []*policy.Condition{{ + SubjectExternalSelectorValue: ".department", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"cardiology"}, + }}, + }}, + }}, + } +} diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index decb42f2c3..2df99356de 100644 --- a/service/internal/access/v2/helpers.go +++ b/service/internal/access/v2/helpers.go @@ -13,6 +13,7 @@ import ( "github.com/opentdf/platform/protocol/go/policy" attrs "github.com/opentdf/platform/protocol/go/policy/attributes" "github.com/opentdf/platform/service/internal/access/v2/obligations" + "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" "github.com/opentdf/platform/service/logger" ) @@ -21,6 +22,7 @@ var ( ErrInvalidAttributeDefinition = errors.New("access: invalid attribute definition") ErrInvalidRegisteredResource = errors.New("access: invalid registered resource") ErrInvalidRegisteredResourceValue = errors.New("access: invalid registered resource value") + ErrInvalidDynamicValueMapping = errors.New("access: invalid dynamic value mapping") ) // getDefinition parses the value FQN and uses it to retrieve the definition from the provided definitions map @@ -197,6 +199,8 @@ func getResourceDecisionableAttributes( entitleableAttributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue, // this is needed to support direct entitlement ad-hoc attribute values entitleableAttributesByDefinitionFQN map[string]*policy.Attribute, + // definitions carrying a dynamic value entitlement mapping also support synthetic values + dynamicMappingsByDefinitionFQN subjectmappingbuiltin.DynamicValueMappingsByDefinitionFQN, // action *policy.Action, resources []*authz.Resource, allowDirectEntitlements bool, @@ -251,24 +255,29 @@ func getResourceDecisionableAttributes( attributeAndValue, ok := entitleableAttributesByValueFQN[attrValueFQN] if !ok { - // if the attribute value FQN is not found, then check if direct entitlements with synthetic values are enabled (experimental) - if !allowDirectEntitlements { - // if disabled, add to not found list and skip to next attribute value FQN + // The value FQN is not a concrete policy value. A synthetic value is created + // when either direct entitlements are enabled (experimental) OR the parent + // definition carries a dynamic value entitlement mapping (DSPX-2754), since + // dynamic mappings entitle values that are not pre-provisioned in policy. + parentDefinition, err := getDefinition(attrValueFQN, entitleableAttributesByDefinitionFQN) + if err != nil { + // definition not found: add to not found list and skip notFoundFQNs = append(notFoundFQNs, attrValueFQN) continue } - // now process direct entitlement that only exists at attribute definition level - logger.DebugContext(ctx, "processing direct entitlement for resource decisionable attribute value", slog.String("attribute_value_fqn", attrValueFQN)) - - // try to find the definition by extracting partial FQN from direct entitlement synthetic value FQN - parentDefinition, err := getDefinition(attrValueFQN, entitleableAttributesByDefinitionFQN) - if err != nil { - // if definition not found, add to not found list and skip to next attribute value FQN + _, hasDynamicMapping := dynamicMappingsByDefinitionFQN[parentDefinition.GetFqn()] + if !allowDirectEntitlements && !hasDynamicMapping { + // neither path enabled for this value: add to not found list and skip notFoundFQNs = append(notFoundFQNs, attrValueFQN) continue } + logger.DebugContext(ctx, "processing synthetic value for resource decisionable attribute value", + slog.String("attribute_value_fqn", attrValueFQN), + slog.Bool("has_dynamic_mapping", hasDynamicMapping), + ) + // Extract the value part from the FQN // FQN format: https:///attr//value/ parsedAttrValueFQN, err := identifier.Parse[*identifier.FullyQualifiedAttribute](attrValueFQN) diff --git a/service/internal/access/v2/helpers_test.go b/service/internal/access/v2/helpers_test.go index 3862c6f8b0..149eaa108a 100644 --- a/service/internal/access/v2/helpers_test.go +++ b/service/internal/access/v2/helpers_test.go @@ -1147,6 +1147,7 @@ func Test_getResourceDecisionableAttributes(t *testing.T) { nil, // registered resources are not used by direct entitlements nil, // direct entitlements will not be in entitleableAttributesByValueFQN map, due to synthetic values entitleableAttributesByDefinitionFQN, + nil, // no definition value entitlement mappings resources, true, // allow direct entitlements ) @@ -1173,6 +1174,7 @@ func Test_getResourceDecisionableAttributes(t *testing.T) { nil, // registered resources are not used by direct entitlements nil, // direct entitlements will not be in entitleableAttributesByValueFQN map, due to synthetic values entitleableAttributesByDefinitionFQN, + nil, // no definition value entitlement mappings resources, true, // allow direct entitlements ) @@ -1195,6 +1197,7 @@ func Test_getResourceDecisionableAttributes(t *testing.T) { nil, // registered resources are not used by direct entitlements nil, // direct entitlements will not be in entitleableAttributesByValueFQN map, due to synthetic values entitleableAttributesByDefinitionFQN, + nil, // no definition value entitlement mappings resources, false, // disable direct entitlements ) diff --git a/service/internal/access/v2/just_in_time_pdp.go b/service/internal/access/v2/just_in_time_pdp.go index d90addfd4a..336b5835e3 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -91,8 +91,12 @@ func NewJustInTimePDP( if err != nil { return nil, fmt.Errorf("failed to fetch all obligations: %w", err) } + allDynamicValueMappings, err := store.ListAllDynamicValueMappings(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch all dynamic value mappings: %w", err) + } - pdp, err := NewPolicyDecisionPoint(ctx, log, allAttributes, allSubjectMappings, allRegisteredResources, allowDirectEntitlements, namespacedPolicy) + pdp, err := NewPolicyDecisionPointWithDynamicValueMappings(ctx, log, allAttributes, allSubjectMappings, allDynamicValueMappings, allRegisteredResources, allowDirectEntitlements, namespacedPolicy) if err != nil { return nil, fmt.Errorf("failed to create new policy decision point: %w", err) } diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 784ea6988e..cb801bad0b 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -61,6 +61,7 @@ type PolicyDecisionPoint struct { allEntitleableAttributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue allRegisteredResourceValuesByFQN map[string]*policy.RegisteredResourceValue allAttributesByDefinitionFQN map[string]*policy.Attribute + dynamicMappingsByDefinitionFQN subjectmappingbuiltin.DynamicValueMappingsByDefinitionFQN allowDirectEntitlements bool namespacedPolicy bool } @@ -85,6 +86,31 @@ func NewPolicyDecisionPoint( allRegisteredResources []*policy.RegisteredResource, allowDirectEntitlements bool, namespacedPolicy bool, +) (*PolicyDecisionPoint, error) { + return NewPolicyDecisionPointWithDynamicValueMappings( + ctx, + l, + allAttributeDefinitions, + allSubjectMappings, + nil, + allRegisteredResources, + allowDirectEntitlements, + namespacedPolicy, + ) +} + +// NewPolicyDecisionPointWithDynamicValueMappings is NewPolicyDecisionPoint +// plus the dynamic, definition-level value entitlement mappings (DSPX-2754). The mappings +// argument may be nil/empty when the feature is unused. +func NewPolicyDecisionPointWithDynamicValueMappings( + ctx context.Context, + l *logger.Logger, + allAttributeDefinitions []*policy.Attribute, + allSubjectMappings []*policy.SubjectMapping, + allDynamicValueMappings []*policy.DynamicValueMapping, + allRegisteredResources []*policy.RegisteredResource, + allowDirectEntitlements bool, + namespacedPolicy bool, ) (*PolicyDecisionPoint, error) { var err error @@ -160,6 +186,43 @@ func NewPolicyDecisionPoint( allEntitleableAttributesByValueFQN[mappedValueFQN] = mapped } + dynamicMappingsByDefinitionFQN := make(subjectmappingbuiltin.DynamicValueMappingsByDefinitionFQN) + for _, mapping := range allDynamicValueMappings { + if err := validateDynamicValueMapping(mapping); err != nil { + l.WarnContext(ctx, + "invalid dynamic value mapping - skipping", + slog.Any("dynamic_value_mapping", mapping), + slog.Any("error", err), + ) + continue + } + + if namespacedPolicy { + ns := mapping.GetNamespace() + if ns == nil || (ns.GetId() == "" && ns.GetFqn() == "") { + l.TraceContext(ctx, + "unnamespaced dynamic value mapping in strict namespaced-policy mode - skipping", + slog.String("reason", "dynamic_value_mapping_namespace_missing"), + slog.String("dynamic_value_mapping_id", mapping.GetId()), + slog.String("attribute_definition_fqn", mapping.GetAttributeDefinition().GetFqn()), + ) + continue + } + } + + definitionFQN := mapping.GetAttributeDefinition().GetFqn() + if _, ok := allAttributesByDefinitionFQN[definitionFQN]; !ok { + l.WarnContext(ctx, + "dynamic value mapping references unknown attribute definition - skipping", + slog.String("dynamic_value_mapping_id", mapping.GetId()), + slog.String("attribute_definition_fqn", definitionFQN), + ) + continue + } + + dynamicMappingsByDefinitionFQN[definitionFQN] = append(dynamicMappingsByDefinitionFQN[definitionFQN], mapping) + } + allRegisteredResourceValuesByFQN := make(map[string]*policy.RegisteredResourceValue) for _, rr := range allRegisteredResources { if err := validateRegisteredResource(rr); err != nil { @@ -192,12 +255,13 @@ func NewPolicyDecisionPoint( } pdp := &PolicyDecisionPoint{ - l, - allEntitleableAttributesByValueFQN, - allRegisteredResourceValuesByFQN, - allAttributesByDefinitionFQN, - allowDirectEntitlements, - namespacedPolicy, + logger: l, + allEntitleableAttributesByValueFQN: allEntitleableAttributesByValueFQN, + allRegisteredResourceValuesByFQN: allRegisteredResourceValuesByFQN, + allAttributesByDefinitionFQN: allAttributesByDefinitionFQN, + dynamicMappingsByDefinitionFQN: dynamicMappingsByDefinitionFQN, + allowDirectEntitlements: allowDirectEntitlements, + namespacedPolicy: namespacedPolicy, } return pdp, nil } @@ -245,6 +309,7 @@ func (p *PolicyDecisionPoint) GetDecision( p.allRegisteredResourceValuesByFQN, p.allEntitleableAttributesByValueFQN, p.allAttributesByDefinitionFQN, /* action, */ + p.dynamicMappingsByDefinitionFQN, resources, p.allowDirectEntitlements, ) @@ -301,6 +366,24 @@ func (p *PolicyDecisionPoint) GetDecision( } } + // Evaluate dynamic, definition-level value entitlement mappings (DSPX-2754) and merge + // their results into the entitled FQNs before rule evaluation. + if len(p.dynamicMappingsByDefinitionFQN) > 0 { + dynamicEntitledFQNsToActions, err := subjectmappingbuiltin.EvaluateDynamicValueMappingsWithActions( + p.dynamicMappingsByDefinitionFQN, + decisionableAttributes, + entityRepresentation, + l.Logger, + ) + if err != nil { + return nil, nil, fmt.Errorf("error evaluating dynamic value mappings: %w", err) + } + for fqn, actions := range dynamicEntitledFQNsToActions { + entitledFQNsToActions[fqn] = append(entitledFQNsToActions[fqn], actions...) + } + l.DebugContext(ctx, "evaluated dynamic value mappings", slog.Any("dynamic_entitled_value_fqns_to_actions", dynamicEntitledFQNsToActions)) + } + decision := &Decision{ AllPermitted: true, Results: make([]ResourceDecision, len(resources)), @@ -355,6 +438,7 @@ func (p *PolicyDecisionPoint) GetDecisionRegisteredResource( p.allRegisteredResourceValuesByFQN, p.allEntitleableAttributesByValueFQN, p.allAttributesByDefinitionFQN, /*action, */ + p.dynamicMappingsByDefinitionFQN, resources, p.allowDirectEntitlements, ) diff --git a/service/internal/access/v2/pdp_dynamic_test.go b/service/internal/access/v2/pdp_dynamic_test.go new file mode 100644 index 0000000000..0b0ef8d001 --- /dev/null +++ b/service/internal/access/v2/pdp_dynamic_test.go @@ -0,0 +1,86 @@ +package access + +import ( + authz "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/policy" +) + +// Test_GetDecision_DynamicValueMapping_MultiValue exercises the full +// GetDecision path for dynamic, definition-level value entitlement (DSPX-2754), focused on +// the multi-value rule semantics: a single resource carries two dynamic values under one +// definition while the entity is entitled to only one. ANY_OF should permit, ALL_OF deny. +func (s *PDPTestSuite) Test_GetDecision_DynamicValueMapping_MultiValue() { + const ns = "hospital.co" + defFQN := createAttrFQN(ns, "mrn") + v123 := createAttrValueFQN(ns, "mrn", "mrn-123") + v456 := createAttrValueFQN(ns, "mrn", "mrn-456") + namespace := &policy.Namespace{Name: ns, Fqn: "https://" + ns} + + buildPDP := func(rule policy.AttributeRuleTypeEnum) *PolicyDecisionPoint { + // A dynamic definition has no statically provisioned values. + attr := &policy.Attribute{ + Fqn: defFQN, + Rule: rule, + Namespace: namespace, + } + mapping := &policy.DynamicValueMapping{ + AttributeDefinition: attr, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: ".properties.patientAssignments[]", + Comparison: policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, + }, + Actions: []*policy.Action{testActionRead}, + Namespace: namespace, + } + pdp, err := NewPolicyDecisionPointWithDynamicValueMappings( + s.T().Context(), + s.logger, + []*policy.Attribute{attr}, + []*policy.SubjectMapping{}, + []*policy.DynamicValueMapping{mapping}, + nil, + false, // allowDirectEntitlements: dynamic mappings synthesize values on their own + false, // namespacedPolicy + ) + s.Require().NoError(err) + s.Require().NotNil(pdp) + return pdp + } + + // Entity is assigned mrn-123 only (entitled to one of the two requested values). + entityOne := s.createEntityWithProps("provider-1", map[string]interface{}{ + "patientAssignments": []interface{}{"mrn-123"}, + }) + // Single resource carrying BOTH dynamic values under the one definition. + resourceBothValues := []*authz.Resource{createAttributeValueResource("resource-1", v123, v456)} + + s.Run("ANY_OF permits when entitled to one of two dynamic values", func() { + pdp := buildPDP(policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + decision, entitlements, err := pdp.GetDecision(s.T().Context(), entityOne, testActionRead, resourceBothValues) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.AllPermitted, "ANY_OF: one entitled dynamic value suffices") + s.Contains(entitlements, v123, "should be entitled to the matched dynamic value") + s.NotContains(entitlements, v456, "should not be entitled to the unmatched dynamic value") + s.Require().Contains(entitlements[v123], testActionRead) + }) + + s.Run("ALL_OF denies when entitled to only one of two dynamic values", func() { + pdp := buildPDP(policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF) + decision, _, err := pdp.GetDecision(s.T().Context(), entityOne, testActionRead, resourceBothValues) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.False(decision.AllPermitted, "ALL_OF: mrn-456 is not entitled, so the resource is denied") + }) + + s.Run("ALL_OF permits when entitled to both dynamic values", func() { + entityBoth := s.createEntityWithProps("provider-2", map[string]interface{}{ + "patientAssignments": []interface{}{"mrn-123", "mrn-456"}, + }) + pdp := buildPDP(policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF) + decision, _, err := pdp.GetDecision(s.T().Context(), entityBoth, testActionRead, resourceBothValues) + s.Require().NoError(err) + s.Require().NotNil(decision) + s.True(decision.AllPermitted, "ALL_OF: both dynamic values are entitled") + }) +} diff --git a/service/internal/access/v2/policy_store.go b/service/internal/access/v2/policy_store.go index f9381e9262..8cd71b78ac 100644 --- a/service/internal/access/v2/policy_store.go +++ b/service/internal/access/v2/policy_store.go @@ -7,6 +7,7 @@ import ( "github.com/opentdf/platform/protocol/go/common" "github.com/opentdf/platform/protocol/go/policy" attrs "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/dynamicvaluemapping" "github.com/opentdf/platform/protocol/go/policy/obligations" "github.com/opentdf/platform/protocol/go/policy/registeredresources" "github.com/opentdf/platform/protocol/go/policy/subjectmapping" @@ -17,6 +18,7 @@ import ( type EntitlementPolicyStore interface { ListAllAttributes(ctx context.Context) ([]*policy.Attribute, error) ListAllSubjectMappings(ctx context.Context) ([]*policy.SubjectMapping, error) + ListAllDynamicValueMappings(ctx context.Context) ([]*policy.DynamicValueMapping, error) ListAllRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) ListAllObligations(ctx context.Context) ([]*policy.Obligation, error) IsEnabled() bool @@ -24,10 +26,11 @@ type EntitlementPolicyStore interface { } var ( - ErrFailedToFetchAttributes = errors.New("failed to fetch attributes from policy service") - ErrFailedToFetchSubjectMappings = errors.New("failed to fetch subject mappings from policy service") - ErrFailedToFetchRegisteredResources = errors.New("failed to fetch registered resources from policy service") - ErrFailedToFetchObligations = errors.New("failed to fetch obligations from policy service") + ErrFailedToFetchAttributes = errors.New("failed to fetch attributes from policy service") + ErrFailedToFetchSubjectMappings = errors.New("failed to fetch subject mappings from policy service") + ErrFailedToFetchDynamicValueMappings = errors.New("failed to fetch dynamic value mappings from policy service") + ErrFailedToFetchRegisteredResources = errors.New("failed to fetch registered resources from policy service") + ErrFailedToFetchObligations = errors.New("failed to fetch obligations from policy service") ) // EntitlementPolicyRetriever satisfies the EntitlementPolicyStore interface and fetches fresh @@ -103,6 +106,32 @@ func (p *EntitlementPolicyRetriever) ListAllSubjectMappings(ctx context.Context) return smList, nil } +func (p *EntitlementPolicyRetriever) ListAllDynamicValueMappings(ctx context.Context) ([]*policy.DynamicValueMapping, error) { + // If quantity exceeds maximum list pagination, all are needed to determine entitlements + var nextOffset int32 + mappingsList := make([]*policy.DynamicValueMapping, 0) + + for { + listed, err := p.SDK.DynamicValueMapping.ListDynamicValueMappings(ctx, &dynamicvaluemapping.ListDynamicValueMappingsRequest{ + // defer to service default for limit pagination + Pagination: &policy.PageRequest{ + Offset: nextOffset, + }, + }) + if err != nil { + return nil, errors.Join(ErrFailedToFetchDynamicValueMappings, err) + } + + nextOffset = listed.GetPagination().GetNextOffset() + mappingsList = append(mappingsList, listed.GetDynamicValueMappings()...) + + if nextOffset <= 0 { + break + } + } + return mappingsList, nil +} + func (p *EntitlementPolicyRetriever) ListAllRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) { // If quantity of registered resources exceeds maximum list pagination, all are needed to determine entitlements var nextOffset int32 diff --git a/service/internal/access/v2/validators.go b/service/internal/access/v2/validators.go index fff3d6e451..8a56b7e0dc 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -127,6 +127,40 @@ func validateAttribute(attribute *policy.Attribute) error { return nil } +// validateDynamicValueMapping validates a dynamic value entitlement mapping +// is usable for an entitlement decision. +// +// mapping: +// +// - must not be nil +// - must reference an attribute definition with a non-empty FQN +// - the definition must not be HIERARCHY (ordered static values are incompatible) +// - must have a value resolver with a selector and a specified operator +// - must have at least one action +func validateDynamicValueMapping(mapping *policy.DynamicValueMapping) error { + if mapping == nil { + return fmt.Errorf("dynamic value mapping is nil: %w", ErrInvalidDynamicValueMapping) + } + def := mapping.GetAttributeDefinition() + if def == nil || def.GetFqn() == "" { + return fmt.Errorf("mapping's attribute definition is missing: %w", ErrInvalidDynamicValueMapping) + } + if def.GetRule() == policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY { + return fmt.Errorf("HIERARCHY definitions are not supported for dynamic value entitlement: %w", ErrInvalidDynamicValueMapping) + } + resolver := mapping.GetValueResolver() + if resolver == nil || resolver.GetSubjectExternalSelectorValue() == "" { + return fmt.Errorf("mapping's value resolver selector is empty: %w", ErrInvalidDynamicValueMapping) + } + if resolver.GetComparison() == policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_UNSPECIFIED { + return fmt.Errorf("mapping's value resolver comparison is unspecified: %w", ErrInvalidDynamicValueMapping) + } + if len(mapping.GetActions()) == 0 { + return fmt.Errorf("mapping's actions are empty: %w", ErrInvalidDynamicValueMapping) + } + return nil +} + // validateRegisteredResource validates the registered resource is valid for an entitlement decision // // registered resource: diff --git a/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go new file mode 100644 index 0000000000..66bca63241 --- /dev/null +++ b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go @@ -0,0 +1,135 @@ +package subjectmappingbuiltin + +import ( + "fmt" + "log/slog" + + "github.com/opentdf/platform/lib/flattening" + "github.com/opentdf/platform/lib/identifier" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/attributes" +) + +// DynamicValueMappingsByDefinitionFQN indexes dynamic mappings by their +// parent attribute definition FQN for O(1) lookup during decisioning. +type DynamicValueMappingsByDefinitionFQN map[string][]*policy.DynamicValueMapping + +// EvaluateDynamicValueMappingsWithActions resolves the dynamic, definition +// level entitlement mappings for the resources under evaluation. For each decisionable +// attribute value it finds the mappings on the value's parent definition, runs the +// optional static SubjectConditionSet gate, then compares the requested resource value +// segment against the entity representation via the mapping's resolver. On a match the +// mapping's actions are entitled on that concrete value FQN. +// +// The output shape matches EvaluateSubjectMappingsWithActions so the PDP can merge the +// two results uniformly before rule evaluation. +func EvaluateDynamicValueMappingsWithActions( + mappingsByDefinitionFQN DynamicValueMappingsByDefinitionFQN, + decisionableAttributes map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue, + entityRepresentation *entityresolutionV2.EntityRepresentation, + l *slog.Logger, +) (AttributeValueFQNsToActions, error) { + entitlementsSet := make(AttributeValueFQNsToActions) + if len(mappingsByDefinitionFQN) == 0 || entityRepresentation == nil { + return entitlementsSet, nil + } + + for _, entity := range entityRepresentation.GetAdditionalProps() { + flattenedEntity, err := flattening.Flatten(entity.AsMap()) + if err != nil { + return nil, fmt.Errorf("failure to flatten entity in definition value entitlement builtin: %w", err) + } + + for valueFQN, attributeAndValue := range decisionableAttributes { + definitionFQN := attributeAndValue.GetAttribute().GetFqn() + mappings := mappingsByDefinitionFQN[definitionFQN] + if len(mappings) == 0 { + continue + } + + segment, err := resourceValueSegment(valueFQN, attributeAndValue.GetValue()) + if err != nil { + return nil, err + } + + // mappings on the same definition are OR-ed together + for _, mapping := range mappings { + matched, err := evaluateDynamicValueMapping(mapping, flattenedEntity, segment) + if err != nil { + return nil, err + } + if !matched { + continue + } + if _, ok := entitlementsSet[valueFQN]; !ok { + entitlementsSet[valueFQN] = make([]*policy.Action, 0) + } + entitlementsSet[valueFQN] = append( + entitlementsSet[valueFQN], + dedupeSubjectMappingActions(mapping.GetActions(), l)..., + ) + } + } + } + + return entitlementsSet, nil +} + +// evaluateDynamicValueMapping returns true when the optional static gate +// passes (if present) AND the dynamic resolver matches the resource value segment. +func evaluateDynamicValueMapping( + mapping *policy.DynamicValueMapping, + entity flattening.Flattened, + segment string, +) (bool, error) { + // optional static pre-gate: all subject sets AND together with normal semantics + for _, subjectSet := range mapping.GetSubjectConditionSet().GetSubjectSets() { + ok, err := EvaluateSubjectSet(subjectSet, entity) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + } + + return evaluateValueResolver(mapping.GetValueResolver(), entity, segment) +} + +// evaluateValueResolver reports whether any entity value resolved by the selector matches the +// requested resource value segment under the resolver's comparison operator. The match is +// existential over the entity values; case sensitivity follows the resolver's case_insensitive flag. +func evaluateValueResolver(resolver *policy.DynamicValueResolver, entity flattening.Flattened, segment string) (bool, error) { + entityValues := flattening.GetFromFlattened(entity, resolver.GetSubjectExternalSelectorValue()) + comparison := resolver.GetComparison() + caseInsensitive := resolver.GetCaseInsensitive().GetValue() + + for _, ev := range entityValues { + if ev == nil { + continue + } + ok, err := compareEntityValue(comparison, caseInsensitive, fmt.Sprintf("%v", ev), segment) + if err != nil { + return false, err + } + if ok { + return true, nil + } + } + return false, nil +} + +// resourceValueSegment returns the concrete value segment for a resource value FQN, +// preferring the value already parsed onto the policy.Value and falling back to parsing +// the FQN. +func resourceValueSegment(valueFQN string, value *policy.Value) (string, error) { + if v := value.GetValue(); v != "" { + return v, nil + } + parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](valueFQN) + if err != nil { + return "", fmt.Errorf("parsing resource value FQN %q: %w", valueFQN, err) + } + return parsed.Value, nil +} diff --git a/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go new file mode 100644 index 0000000000..d43bb8fb38 --- /dev/null +++ b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go @@ -0,0 +1,181 @@ +package subjectmappingbuiltin + +import ( + "log/slog" + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func dvemEntityRep(t *testing.T, props map[string]interface{}) *entityresolutionV2.EntityRepresentation { + t.Helper() + s, err := structpb.NewStruct(props) + require.NoError(t, err) + return &entityresolutionV2.EntityRepresentation{ + OriginalId: "entity-1", + AdditionalProps: []*structpb.Struct{s}, + } +} + +func dvemActions(names ...string) []*policy.Action { + out := make([]*policy.Action, 0, len(names)) + for _, n := range names { + out = append(out, &policy.Action{Name: n}) + } + return out +} + +func dvemActionNames(acts []*policy.Action) []string { + out := make([]string, 0, len(acts)) + for _, a := range acts { + out = append(out, a.GetName()) + } + return out +} + +func dvemDecisionable(defFQN, valueFQN, segment string) map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue { + return map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{ + valueFQN: { + Value: &policy.Value{Fqn: valueFQN, Value: segment}, + Attribute: &policy.Attribute{Fqn: defFQN}, + }, + } +} + +func dvemMapping(defFQN, selector string, comparison policy.ConditionComparisonOperatorEnum, caseInsensitive bool, scs *policy.SubjectConditionSet, actionNames ...string) *policy.DynamicValueMapping { + return &policy.DynamicValueMapping{ + AttributeDefinition: &policy.Attribute{Fqn: defFQN}, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: selector, + Comparison: comparison, + CaseInsensitive: wrapperspb.Bool(caseInsensitive), + }, + SubjectConditionSet: scs, + Actions: dvemActions(actionNames...), + } +} + +// TestEvaluateDynamicValueMappings_MRNExample replays the ADR#266 worked +// example (patient / provider / nurse) against the production evaluator. +func TestEvaluateDynamicValueMappings_MRNExample(t *testing.T) { + const def = "https://hospital.co/attr/mrn" + const valueFQN = "https://hospital.co/attr/mrn/value/mrn-123" + + cases := []struct { + name string + selector string + props map[string]interface{} + acts []string + wantMatch bool + }{ + {"patient", ".medicalRecordNumber", map[string]interface{}{"medicalRecordNumber": "mrn-123"}, []string{"read", "update_profile"}, true}, + {"provider", ".patientAssignments[]", map[string]interface{}{"patientAssignments": []interface{}{"mrn-123", "mrn-789"}}, []string{"read", "write_order", "update_chart"}, true}, + {"nurse", ".careTeamAssignments[]", map[string]interface{}{"careTeamAssignments": []interface{}{"mrn-123"}}, []string{"read", "update_chart"}, true}, + {"unassigned", ".patientAssignments[]", map[string]interface{}{"patientAssignments": []interface{}{"mrn-456"}}, []string{"read"}, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mapping := dvemMapping(def, tc.selector, policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, false, nil, tc.acts...) + byDef := DynamicValueMappingsByDefinitionFQN{def: {mapping}} + + got, err := EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(def, valueFQN, "mrn-123"), dvemEntityRep(t, tc.props), slog.Default()) + require.NoError(t, err) + if tc.wantMatch { + assert.ElementsMatch(t, tc.acts, dvemActionNames(got[valueFQN])) + } else { + assert.Empty(t, got[valueFQN]) + } + }) + } +} + +// TestEvaluateDynamicValueMappings_Canonicalization covers the external +// system case-mismatch concern: the IdP reports MRN-123, policy stores mrn-123. +func TestEvaluateDynamicValueMappings_Canonicalization(t *testing.T) { + const def = "https://hospital.co/attr/mrn" + const valueFQN = "https://hospital.co/attr/mrn/value/mrn-123" + mapping := dvemMapping(def, ".medicalRecordNumber", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, true, nil, "read") + byDef := DynamicValueMappingsByDefinitionFQN{def: {mapping}} + + got, err := EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(def, valueFQN, "mrn-123"), dvemEntityRep(t, map[string]interface{}{"medicalRecordNumber": "MRN-123"}), slog.Default()) + require.NoError(t, err) + assert.Equal(t, []string{"read"}, dvemActionNames(got[valueFQN])) +} + +// TestEvaluateDynamicValueMappings_InContains covers the substring operator. +func TestEvaluateDynamicValueMappings_InContains(t *testing.T) { + const def = "https://acme.co/attr/group" + const valueFQN = "https://acme.co/attr/group/value/team" + mapping := dvemMapping(def, ".groups[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_CONTAINS, false, nil, "read") + byDef := DynamicValueMappingsByDefinitionFQN{def: {mapping}} + + got, err := EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(def, valueFQN, "team"), dvemEntityRep(t, map[string]interface{}{"groups": []interface{}{"prefix-team-suffix"}}), slog.Default()) + require.NoError(t, err) + assert.Equal(t, []string{"read"}, dvemActionNames(got[valueFQN])) +} + +// TestEvaluateDynamicValueMappings_StaticGate covers the optional static +// SubjectConditionSet pre-gate combined with the dynamic resolver. +func TestEvaluateDynamicValueMappings_StaticGate(t *testing.T) { + const def = "https://hospital.co/attr/mrn" + const valueFQN = "https://hospital.co/attr/mrn/value/mrn-123" + + scs := &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{{ + ConditionGroups: []*policy.ConditionGroup{{ + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + Conditions: []*policy.Condition{{ + SubjectExternalSelectorValue: ".department", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"cardiology"}, + }}, + }}, + }}, + } + mapping := dvemMapping(def, ".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, false, scs, "read") + byDef := DynamicValueMappingsByDefinitionFQN{def: {mapping}} + + // cardiology provider assigned to mrn-123 -> gate + resolver pass + got, err := EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(def, valueFQN, "mrn-123"), dvemEntityRep(t, map[string]interface{}{ + "department": "cardiology", + "patientAssignments": []interface{}{"mrn-123"}, + }), slog.Default()) + require.NoError(t, err) + assert.Equal(t, []string{"read"}, dvemActionNames(got[valueFQN])) + + // wrong department -> static gate fails -> no entitlement + got, err = EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(def, valueFQN, "mrn-123"), dvemEntityRep(t, map[string]interface{}{ + "department": "oncology", + "patientAssignments": []interface{}{"mrn-123"}, + }), slog.Default()) + require.NoError(t, err) + assert.Empty(t, got[valueFQN]) +} + +// TestEvaluateDynamicValueMappings_CrossDefinitionNoLeak verifies a mapping +// only applies to its own definition: the same value segment under a different definition +// is not entitled. +func TestEvaluateDynamicValueMappings_CrossDefinitionNoLeak(t *testing.T) { + const defA = "https://a.co/attr/x" + const defB = "https://b.co/attr/y" + mapping := dvemMapping(defA, ".assignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, false, nil, "read") + byDef := DynamicValueMappingsByDefinitionFQN{defA: {mapping}} + entity := dvemEntityRep(t, map[string]interface{}{"assignments": []interface{}{"shared-1"}}) + + // under definition A -> entitled + gotA, err := EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(defA, defA+"/value/shared-1", "shared-1"), entity, slog.Default()) + require.NoError(t, err) + assert.Equal(t, []string{"read"}, dvemActionNames(gotA[defA+"/value/shared-1"])) + + // same segment under definition B -> not entitled + gotB, err := EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(defB, defB+"/value/shared-1", "shared-1"), entity, slog.Default()) + require.NoError(t, err) + assert.Empty(t, gotB[defB+"/value/shared-1"]) +} diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin.go index d849c0afce..ffb0bee068 100644 --- a/service/internal/subjectmappingbuiltin/subject_mapping_builtin.go +++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin.go @@ -205,65 +205,117 @@ ConditionEval: func EvaluateCondition(condition *policy.Condition, entity flattening.Flattened) (bool, error) { mappedValues := flattening.GetFromFlattened(entity, condition.GetSubjectExternalSelectorValue()) - // slog.Debug("mapped values", "", mappedValues) - result := false - switch condition.GetOperator() { - case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN: - // slog.Debug("the operator is IN") - for _, possibleValue := range condition.GetSubjectExternalValues() { - // slog.Debug("possible value", "", possibleValue) - for _, mappedValue := range mappedValues { - // slog.Debug("comparing values: ", "possible=", possibleValue, "mapped=", mappedValue) - if possibleValue == mappedValue { - // slog.Debug("comparison true") - result = true - break - } + comparison, quantifier := normalizedConditionOperators(condition) + caseInsensitive := condition.GetCaseInsensitive().GetValue() + + // matches reports whether any mapped (entity) value matches the given comparison value. + matches := func(comparisonValue string) (bool, error) { + for _, mappedValue := range mappedValues { + ok, err := compareEntityValue(comparison, caseInsensitive, fmt.Sprintf("%v", mappedValue), comparisonValue) + if err != nil { + return false, err } - if result { - break + if ok { + return true, nil } } - case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN: - notInResult := true - for _, possibleValue := range condition.GetSubjectExternalValues() { - for _, mappedValue := range mappedValues { - // slog.Debug("comparing values: ", "possible=", possibleValue, "mapped=", mappedValue) - if possibleValue == mappedValue { - // slog.Debug("comparison true") - notInResult = false - break - } + return false, nil + } + + switch quantifier { + case policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_ANY: + // at least one subject_external_values entry matches at least one mapped value + for _, comparisonValue := range condition.GetSubjectExternalValues() { + ok, err := matches(comparisonValue) + if err != nil { + return false, err } - if !notInResult { - break + if ok { + return true, nil } } - if notInResult { - result = true + return false, nil + case policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_NONE: + // no subject_external_values entry matches any mapped value + for _, comparisonValue := range condition.GetSubjectExternalValues() { + ok, err := matches(comparisonValue) + if err != nil { + return false, err + } + if ok { + return false, nil + } } - case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS: - // slog.Debug("the operator is CONTAINS") - for _, possibleValue := range condition.GetSubjectExternalValues() { - // slog.Debug("possible value", "", possibleValue) - for _, mappedValue := range mappedValues { - mappedValueStr := fmt.Sprintf("%v", mappedValue) - // slog.Debug("comparing values: ", "possible=", possibleValue, "mapped=", mappedValueStr) - if strings.Contains(mappedValueStr, possibleValue) { - result = true - break - } + return true, nil + case policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_ALL: + // every subject_external_values entry matches at least one mapped value + for _, comparisonValue := range condition.GetSubjectExternalValues() { + ok, err := matches(comparisonValue) + if err != nil { + return false, err } - if result { - break + if !ok { + return false, nil } } + return true, nil + case policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_UNSPECIFIED: + return false, errors.New("unspecified condition quantifier") + default: + return false, errors.New("unsupported condition quantifier: " + quantifier.String()) + } +} + +// normalizedConditionOperators returns the comparison and quantifier to evaluate a condition with. +// When the decomposed fields are unset it derives them from the deprecated operator field, so +// conditions authored before the decomposition keep working unchanged. +func normalizedConditionOperators(condition *policy.Condition) (policy.ConditionComparisonOperatorEnum, policy.ConditionQuantifierEnum) { + comparison := condition.GetComparison() + quantifier := condition.GetQuantifier() + if comparison != policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_UNSPECIFIED || + quantifier != policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_UNSPECIFIED { + return comparison, quantifier + } + switch condition.GetOperator() { //nolint:staticcheck // deprecated operator retained for backward-compat normalization + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN: + return policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_ANY + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN: + return policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_NONE + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS: + return policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_CONTAINS, policy.ConditionQuantifierEnum_CONDITION_QUANTIFIER_ENUM_ANY case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_UNSPECIFIED: - // unspecified subject mapping operator - return false, errors.New("unspecified subject mapping operator: " + condition.GetOperator().String()) + // leaves both UNSPECIFIED so EvaluateCondition returns a clear error + return comparison, quantifier + default: + // leaves both UNSPECIFIED so EvaluateCondition returns a clear error + return comparison, quantifier + } +} + +// compareEntityValue reports whether an entity value matches a comparison value under the given +// comparison operator. The entity value is the haystack and the comparison value is the needle, +// matching both the static condition (entity value vs authored value) and the dynamic resolver +// (entity value vs requested resource segment). Both operands are whitespace-trimmed, and folded +// to lower case when caseInsensitive. +func compareEntityValue(comparison policy.ConditionComparisonOperatorEnum, caseInsensitive bool, entityValue, comparisonValue string) (bool, error) { + a := strings.TrimSpace(entityValue) + b := strings.TrimSpace(comparisonValue) + if caseInsensitive { + a = strings.ToLower(a) + b = strings.ToLower(b) + } + switch comparison { + case policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS: + return a == b, nil + case policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_CONTAINS: + return strings.Contains(a, b), nil + case policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_STARTS_WITH: + return strings.HasPrefix(a, b), nil + case policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_ENDS_WITH: + return strings.HasSuffix(a, b), nil + case policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_UNSPECIFIED: + return false, errors.New("unspecified condition comparison operator") default: - // unsupported subject mapping operator - return false, errors.New("unsupported subject mapping operator: " + condition.GetOperator().String()) + return false, fmt.Errorf("unsupported condition comparison operator: %s", comparison) } - return result, nil } diff --git a/service/logger/audit/constants.go b/service/logger/audit/constants.go index 9a0bb2db1b..af6a06f9ba 100644 --- a/service/logger/audit/constants.go +++ b/service/logger/audit/constants.go @@ -32,6 +32,7 @@ const ( ObjectTypeKasAttributeDefinitionKeyAssignment ObjectTypeKasAttributeValueKeyAssignment ObjectTypeKasAttributeNamespaceKeyAssignment + ObjectTypeDynamicValueMapping ) func (ot ObjectType) String() string { @@ -61,6 +62,7 @@ func (ot ObjectType) String() string { "kas_attribute_definition_key_assignment", "kas_attribute_value_key_assignment", "kas_attribute_namespace_key_assignment", + "dynamic_value_mapping", }[ot] } diff --git a/service/policy/adr/0005-dynamic-attribute-value-entitlements-spike.md b/service/policy/adr/0005-dynamic-attribute-value-entitlements-spike.md new file mode 100644 index 0000000000..eb5fdaece6 --- /dev/null +++ b/service/policy/adr/0005-dynamic-attribute-value-entitlements-spike.md @@ -0,0 +1,140 @@ +# Dynamic Attribute Value Entitlement + +Entitling highly dynamic, high-cardinality attribute values (medical record numbers, account IDs, +email-like identifiers) is impractical today: each value must be duplicated as an `AttributeValue` and +paired with its own `SubjectMapping` + `SubjectConditionSet`, then kept constantly in sync with an +external system of record. The cross-repo ADR [virtru-corp/adr#266](https://github.com/virtru-corp/adr/pull/266) +chose a definition-level dynamic entitlement model (its Option 3) but **explicitly deferred to an +implementation spike** the question of *how* to model it. This document records what that spike +([DSPX-2754](https://virtru.atlassian.net/browse/DSPX-2754)) found. + +The original spike prototyped all three options as a throwaway package to make them comparable on real +behavior. The recommendation below (a new primitive carrying a new operator) is now implemented as +production code: the `DynamicValueMapping` primitive +([`service/policy/objects.proto`](../objects.proto)), its dedicated +[`DynamicValueMappingService`](../dynamicvaluemapping), DB layer, and the decision-time evaluator +([`service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go`](../../internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go)) +wired into the PDP. The findings below record why that shape was chosen over the alternatives. + +> [!NOTE] +> The upstream ADR ([virtru-corp/adr#266](https://github.com/virtru-corp/adr/pull/266)) named this +> primitive `DefinitionValueEntitlementMapping` but explicitly noted that primitive names are subject to +> change during implementation. It is implemented here as `DynamicValueMapping`, which is shorter, omits +> the redundant "Entitlement" (consistent with `SubjectMapping`/`ResourceMapping`), and avoids overloading +> the authorization-runtime term "entitlement". + +## Context + +How should condition-set authority be moved up from the `AttributeValue` to the `AttributeDefinition`? +Four shapes were on the table (from the ADR discussion threads): reuse Subject Mappings, add a new +primitive, add a new attribute rule, or add a new operator. + +## Recommendation: a new primitive (`DynamicValueMapping`) carrying a new operator + +The spike recommends a **new first-class primitive** scoped to an `AttributeDefinition`, holding a +`selector`, a **new dynamic operator**, and `actions`. The four "options" are not mutually exclusive: the +new operator is the shared mechanic *every* shape needs, and the new primitive is the cleanest container +for it. Reuse-of-subject-mappings and a new-attribute-rule were both prototyped and found to carry +avoidable downsides (below). + +### Shared Mechanic: a new operator (required by every option) + +Existing condition evaluation compares an entity's selector result against a **static list authored into +policy** (`policy.Condition.subject_external_values`; see +[`subjectmappingbuiltin.EvaluateCondition`](../../internal/subjectmappingbuiltin/subject_mapping_builtin.go)). +The dynamic case **inverts** the comparison: the right-hand operand is the **resource's value segment** +(e.g. `mrn-123`, parsed from `…/value/mrn-123`), known only at decision time, tested for membership in the +entity's selector-resolved set (e.g. `.patientAssignments` → `["mrn-123","mrn-789"]`). + +This inversion cannot be expressed by the current operators, so a new operator is unavoidable regardless +of container. The spike implements `RESOURCE_VALUE_IN` (and `RESOURCE_VALUE_IN_CONTAINS`) as the inversion +of `IN` / `IN_CONTAINS`. Per @jrschumacher's feedback on the ADR, the operator name should make the +direction explicit, so `RESOURCE_VALUE_IN` reads as "the resource value is in the selector result". + +This single function (`evaluateDynamicMatch` in `core.go`) backs all three prototyped shapes. The +`TestMRNExampleAcrossAllShapes` test replays the ADR's worked example against all three and confirms they +decide identically. The shapes therefore differ only in schema, admin UX, and enforcement, not behavior. + +## Options + +| Dimension | A. Reuse Subject Mappings | B. New Primitive (recommended) | C. New Attribute Rule | +| --- | --- | --- | --- | +| Expresses "dynamic" in schema | ✗ must overload `subject_external_values` with a sentinel | ✓ typed fields, intent explicit | ◑ rule value implies it | +| Operator field honesty | ✗ static `SubjectMappingOperatorEnum` reused for dynamic meaning | ✓ typed to dynamic operators only | ✓ | +| Combination rule (ANY_OF/ALL_OF) still available | ✓ orthogonal | ✓ orthogonal | ✗ rule slot consumed (see below) | +| Reuses existing evaluator code | ✓ partial (static leaves) | ✗ (new, small) | ✗ | +| Mixed static + dynamic conditions | ✓ supported | ✗ would need a companion subject mapping | ✗ | +| Admin/UX clarity | ✗ "why is this subject mapping on a definition?" | ✓ distinct object, distinct mental model | ◑ overloads "rule" concept | +| Migration drift from today | low (same tables) | medium (new table/proto) | medium | + +### A. Reuse Subject Mappings (Prototyped, Not Recommended) + +The existing `SubjectConditionSet` was re-scoped from an `AttributeValue` to an `AttributeDefinition` +(`DefinitionScopedSubjectMapping`). It reuses the AND/OR condition-group plumbing and the static leaf +evaluator, and it uniquely supports **mixed static + dynamic conditions** (e.g. "department is cardiology +AND the resource MRN is in your assignments"; see `TestReuseStaticAndDynamicConditions`). + +But the `SubjectConditionSet` schema has no way to mark a condition as dynamic, so the prototype overloads +`subject_external_values` with a `${resource.value}` sentinel. This is fragile: it is invisible to existing +tooling, easy to mistype, and reuses a field that everywhere else holds a static list. It also forces a +near-duplicate of the group-walk, because the production walk is hard-wired to the static leaf evaluator. +Reuse keeps table and migration drift low but reduces clarity. This answers @strantalis's and @biscoe916's +"why not just extend subject mappings?": it can be done, but the result reads less clearly than a +purpose-built object. + +### C. New Attribute Rule (Prototyped, Not Recommended) + +Modeling dynamic as a new `AttributeRuleTypeEnum` value (`RuleDynamic`) conflates two separate ideas. The +rule slot already encodes how *multiple values on one definition combine* (`ANY_OF` / `ALL_OF` / +`HIERARCHY`). Using that slot to describe how values are *entitled* means a dynamic definition can no +longer state its combination semantics. In the prototype, `RuleDynamic` defaults to `ANY_OF`, which hides +that choice from the author. How values are entitled and how they combine are separate concerns and should +not share one field. + +## Edge Cases (all exercised by tests) + +- **Character Set / FQN Ambiguity** (@jentfoo): value segments must never contain FQN-structural or + encoding characters (`/`, `.`, `%`, NUL) or non-ASCII. The spike enforces this floor + (`validateValueSegment`) independently of any future loosening of the value grammar. As a consequence, the + **current** value grammar (`lib/identifier`, strictly `[a-zA-Z0-9_-]`) already cannot represent + email-like identifiers (`user@acme.co` fails to parse). If the owner/email use case is in scope, the + value grammar must be deliberately widened, but only to a set that excludes the ambiguous characters + above. +- **Canonicalization** (@biscoe916): external systems disagree with policy on case and whitespace. Without a + normalization step, `MRN-123` from the IdP fails to match `mrn-123` in the FQN. The spike applies a + pluggable `Canonicalizer` (default: lowercase + trim) to both sides. `TestCanonicalization` shows the + match succeed with it and fail without it. A real implementation must decide where canonicalization is + authoritative and whether it is configurable per definition. +- **Cross-Definition / Namespace Collisions** (@jakedoublev): because entitlement is keyed to the value's + *parent definition FQN*, the same pass-through segment under a different definition is **not** granted + (`TestCrossDefinitionNoLeak`). This is the key advantage of entitling concrete value FQNs over entitling + bare pass-through values. +- **Multi-Value Resources** (ADR decision-flow step 6): a single resource carrying several values under + one definition evaluates the definition rule normally. `TestDecideMultiValue` covers `ANY_OF` (one match + suffices) and `ALL_OF` (every value must match). +- **API Enforcement**: a definition must not carry both a value-level static subject mapping and a dynamic + mapping (`ValidateNoCoexistence`), and `HIERARCHY` definitions are rejected for dynamic entitlement since + they require statically ordered values (`ValidateRule`). +- **Direct-Entitlements Overlap / Migration** (@biscoe916 Q1): a direct entitlement is effectively a + `(value FQN, actions)` pair sourced from ERS at decision time. `TestDirectEntitlementOverlap` shows the + dynamic mapping reproduces the identical grant from a single policy artifact, supporting the + "cover the common case in policy, keep direct entitlements/EPOP for true remote entitlement" path. + +## Open Questions + +1. **Selector Syntax**: the existing flattener addresses array elements as `.patientAssignments[]`, not + the `.patientAssignments` shown in the ADR. The selector grammar surfaced to admins should be specified + and documented. +2. **ERS Trust** (@jentfoo, @jrschumacher): like all entitlement, this trusts the ERS response. The + dynamic model does not worsen that posture but also does not improve it. Provenance/MITM mitigations + remain future work. +3. **Persistence**: where the new primitive's selector values live for any match-acceleration analogous to + the cached `subject_condition_set.selector_values` column. +4. **Canonicalization Authority**: per-definition configuration vs a single global normalization. +5. **Value Grammar**: whether/how far to widen the allowed value character set for the email/owner use case. + +## Out Of Scope + +The broader options (do nothing, productize direct entitlements, plugin PDP) were already decided in +[virtru-corp/adr#266](https://github.com/virtru-corp/adr/pull/266). This spike only covers how to model the +chosen definition-level approach. diff --git a/service/policy/db/attributes.go b/service/policy/db/attributes.go index 9d79ac9332..9b11c11d49 100644 --- a/service/policy/db/attributes.go +++ b/service/policy/db/attributes.go @@ -463,6 +463,20 @@ func (c PolicyDBClient) UnsafeUpdateAttribute(ctx context.Context, r *unsafe.Uns } } + // Guard the reverse of validateDynamicValueMappingAttribute: a definition + // with a dynamic value entitlement mapping cannot be changed to HIERARCHY, which requires + // statically ordered values incompatible with pass-through dynamic values (DSPX-2754). + if rule == policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY && before.GetRule() != policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY { + dynamicCount, err := c.queries.countDynamicValueMappingsByDefinitionID(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if dynamicCount > 0 { + return nil, errors.Join(db.ErrRestrictViolation, + fmt.Errorf("attribute definition [%s] has a definition value entitlement mapping; its rule cannot be changed to HIERARCHY", id)) + } + } + // Handle case where rule is not actually being updated ruleString := "" if rule != policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED { diff --git a/service/policy/db/dynamic_value_mappings.go b/service/policy/db/dynamic_value_mappings.go new file mode 100644 index 0000000000..67249cad99 --- /dev/null +++ b/service/policy/db/dynamic_value_mappings.go @@ -0,0 +1,405 @@ +package db + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/dynamicvaluemapping" + "github.com/opentdf/platform/service/pkg/db" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +type dynamicValueMappingRow struct { + id string + attributeDefinitionID string + subjectExternalSelectorValue string + comparison int16 + caseInsensitive bool + subjectConditionSetID pgtype.UUID + actions interface{} + metadata []byte + namespace interface{} +} + +func (c PolicyDBClient) CreateDynamicValueMapping(ctx context.Context, r *dynamicvaluemapping.CreateDynamicValueMappingRequest) (*policy.DynamicValueMapping, error) { + resolver := r.GetValueResolver() + if resolver.GetComparison() == policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_UNSPECIFIED { + return nil, errors.Join(db.ErrEnumValueInvalid, errors.New("value_resolver.comparison must be specified")) + } + + attr, err := c.resolveDynamicValueMappingAttribute(ctx, r.GetAttributeDefinitionId(), r.GetAttributeDefinitionFqn()) + if err != nil { + return nil, err + } + if err := validateDynamicValueMappingAttribute(attr); err != nil { + return nil, err + } + + // Enforce no-coexistence: a definition cannot have both value-level subject mappings + // and a dynamic value entitlement mapping (DSPX-2754 / ADR 0005). + if err := c.ensureNoValueSubjectMappingCoexistence(ctx, attr.GetId()); err != nil { + return nil, err + } + + resolvedNamespaceID, err := c.resolveNamespace(ctx, r.GetNamespaceId(), r.GetNamespaceFqn()) + if err != nil { + return nil, err + } + parsedNamespaceID := pgtypeUUID(resolvedNamespaceID) + + actionIDs, err := c.resolveSubjectMappingActions(ctx, r.GetActions(), parsedNamespaceID) + if err != nil { + return nil, err + } + + scs, err := c.resolveDynamicValueMappingSubjectConditionSet(ctx, r, resolvedNamespaceID) + if err != nil { + return nil, err + } + + if err := c.validateDynamicValueMappingNamespaceConsistency(ctx, resolvedNamespaceID, attr, actionIDs, scs); err != nil { + return nil, err + } + + metadataJSON, _, err := db.MarshalCreateMetadata(r.GetMetadata()) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + createdID, err := c.queries.createDynamicValueMapping(ctx, createDynamicValueMappingParams{ + AttributeDefinitionID: attr.GetId(), + SubjectExternalSelectorValue: resolver.GetSubjectExternalSelectorValue(), + Comparison: int16(resolver.GetComparison()), + CaseInsensitive: resolver.GetCaseInsensitive().GetValue(), + Metadata: metadataJSON, + SubjectConditionSetID: pgtypeUUID(scs.GetId()), + NamespaceID: parsedNamespaceID, + ActionIds: actionIDs, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + return c.GetDynamicValueMapping(ctx, createdID) +} + +func (c PolicyDBClient) GetDynamicValueMapping(ctx context.Context, id string) (*policy.DynamicValueMapping, error) { + row, err := c.queries.getDynamicValueMapping(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if row.ID == "" { + return nil, db.ErrNotFound + } + + return c.hydrateDynamicValueMapping(ctx, dynamicValueMappingRow{ + id: row.ID, + attributeDefinitionID: row.AttributeDefinitionID, + subjectExternalSelectorValue: row.SubjectExternalSelectorValue, + comparison: row.Comparison, + caseInsensitive: row.CaseInsensitive, + subjectConditionSetID: row.SubjectConditionSetID, + actions: row.Actions, + metadata: row.Metadata, + namespace: row.Namespace, + }) +} + +func (c PolicyDBClient) ListDynamicValueMappings(ctx context.Context, r *dynamicvaluemapping.ListDynamicValueMappingsRequest) (*dynamicvaluemapping.ListDynamicValueMappingsResponse, error) { + limit, offset := c.getRequestedLimitOffset(r.GetPagination()) + + maxLimit := c.listCfg.limitMax + if maxLimit > 0 && limit > maxLimit { + return nil, db.ErrListLimitTooLarge + } + + sortField, sortDirection := GetDynamicValueMappingsSortParams(r.GetSort()) + + rows, err := c.queries.listDynamicValueMappings(ctx, listDynamicValueMappingsParams{ + NamespaceID: pgtypeUUID(r.GetNamespaceId()), + NamespaceFqn: pgtypeText(r.GetNamespaceFqn()), + AttributeDefinitionID: pgtypeUUID(r.GetAttributeDefinitionId()), + Limit: limit, + Offset: offset, + SortField: sortField, + SortDirection: sortDirection, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + mappings := make([]*policy.DynamicValueMapping, len(rows)) + for i, row := range rows { + mapping, err := c.hydrateDynamicValueMapping(ctx, dynamicValueMappingRow{ + id: row.ID, + attributeDefinitionID: row.AttributeDefinitionID, + subjectExternalSelectorValue: row.SubjectExternalSelectorValue, + comparison: row.Comparison, + caseInsensitive: row.CaseInsensitive, + subjectConditionSetID: row.SubjectConditionSetID, + actions: row.Actions, + metadata: row.Metadata, + namespace: row.Namespace, + }) + if err != nil { + return nil, err + } + mappings[i] = mapping + } + + var ( + total int32 + nextOffset int32 + ) + if len(rows) > 0 { + total = int32(rows[0].Total) + nextOffset = getNextOffset(offset, limit, total) + } + + return &dynamicvaluemapping.ListDynamicValueMappingsResponse{ + DynamicValueMappings: mappings, + Pagination: &policy.PageResponse{ + CurrentOffset: offset, + Total: total, + NextOffset: nextOffset, + }, + }, nil +} + +func (c PolicyDBClient) UpdateDynamicValueMapping(ctx context.Context, r *dynamicvaluemapping.UpdateDynamicValueMappingRequest) (*policy.DynamicValueMapping, error) { + id := r.GetId() + before, err := c.GetDynamicValueMapping(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + metadataJSON, _, err := db.MarshalUpdateMetadata(r.GetMetadata(), r.GetMetadataUpdateBehavior(), func() (*common.Metadata, error) { + return before.GetMetadata(), nil + }) + if err != nil { + return nil, err + } + + updateParams := updateDynamicValueMappingParams{ + ID: id, + Metadata: metadataJSON, + SubjectConditionSetID: pgtypeUUID(r.GetSubjectConditionSetId()), + } + + if resolver := r.GetValueResolver(); resolver != nil { + if resolver.GetComparison() == policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_UNSPECIFIED { + return nil, errors.Join(db.ErrEnumValueInvalid, errors.New("value_resolver.comparison must be specified")) + } + updateParams.SubjectExternalSelectorValue = pgtypeText(resolver.GetSubjectExternalSelectorValue()) + updateParams.Comparison = pgtype.Int2{Int16: int16(resolver.GetComparison()), Valid: true} + updateParams.CaseInsensitive = pgtype.Bool{Bool: resolver.GetCaseInsensitive().GetValue(), Valid: true} + } + + targetNamespaceID := before.GetNamespace().GetId() + if actions := r.GetActions(); actions != nil { + actionIDs, err := c.resolveSubjectMappingActions(ctx, actions, pgtypeUUID(targetNamespaceID)) + if err != nil { + return nil, err + } + updateParams.ActionIds = actionIDs + } + + count, err := c.queries.updateDynamicValueMapping(ctx, updateParams) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if count == 0 { + return nil, db.ErrNotFound + } + + return c.GetDynamicValueMapping(ctx, id) +} + +func (c PolicyDBClient) DeleteDynamicValueMapping(ctx context.Context, id string) (*policy.DynamicValueMapping, error) { + count, err := c.queries.deleteDynamicValueMapping(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if count == 0 { + return nil, db.ErrNotFound + } + + return &policy.DynamicValueMapping{Id: id}, nil +} + +func (c PolicyDBClient) hydrateDynamicValueMapping(ctx context.Context, row dynamicValueMappingRow) (*policy.DynamicValueMapping, error) { + metadata := &common.Metadata{} + if err := unmarshalMetadata(row.metadata, metadata); err != nil { + return nil, err + } + + actionsBytes, err := json.Marshal(row.actions) + if err != nil { + return nil, fmt.Errorf("failed to marshal dynamic value mapping actions from interface{}: %w", err) + } + actions := []*policy.Action{} + if err := unmarshalActionsProto(actionsBytes, &actions); err != nil { + return nil, err + } + + attr, err := c.GetAttribute(ctx, row.attributeDefinitionID) + if err != nil { + return nil, err + } + + namespace, err := hydrateNamespaceFromInterface(row.namespace) + if err != nil { + return nil, err + } + + mapping := &policy.DynamicValueMapping{ + Id: row.id, + AttributeDefinition: attr, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: row.subjectExternalSelectorValue, + Comparison: policy.ConditionComparisonOperatorEnum(row.comparison), + CaseInsensitive: wrapperspb.Bool(row.caseInsensitive), + }, + Actions: actions, + Namespace: namespace, + Metadata: metadata, + } + + // Optional static pre-gate. + if row.subjectConditionSetID.Valid { + scs, err := c.GetSubjectConditionSet(ctx, UUIDToString(row.subjectConditionSetID)) + if err != nil { + return nil, err + } + mapping.SubjectConditionSet = scs + } + + return mapping, nil +} + +func (c PolicyDBClient) resolveDynamicValueMappingAttribute(ctx context.Context, id, fqn string) (*policy.Attribute, error) { + switch { + case id != "": + return c.GetAttribute(ctx, id) + case fqn != "": + return c.GetAttribute(ctx, &attributes.GetAttributeRequest_Fqn{Fqn: fqn}) + default: + return nil, db.WrapIfKnownInvalidQueryErr( + errors.Join(db.ErrMissingValue, errors.New("either an attribute definition ID or FQN is required")), + ) + } +} + +func validateDynamicValueMappingAttribute(attr *policy.Attribute) error { + switch attr.GetRule() { + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, + policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: + return nil + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: + return errors.Join(db.ErrEnumValueInvalid, errors.New("dynamic value mappings do not support HIERARCHY attributes")) + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED: + fallthrough + default: + return errors.Join(db.ErrEnumValueInvalid, errors.New("dynamic value mappings require ALL_OF or ANY_OF attributes")) + } +} + +// ensureNoValueSubjectMappingCoexistence rejects creation of a dynamic mapping when the +// definition's values already carry value-level subject mappings. +func (c PolicyDBClient) ensureNoValueSubjectMappingCoexistence(ctx context.Context, definitionID string) error { + count, err := c.queries.countValueSubjectMappingsByDefinitionID(ctx, definitionID) + if err != nil { + return db.WrapIfKnownInvalidQueryErr(err) + } + if count > 0 { + return errors.Join(db.ErrRestrictViolation, + fmt.Errorf("attribute definition [%s] already has value-level subject mappings; it cannot also have a dynamic value mapping", definitionID)) + } + return nil +} + +// ensureNoDynamicValueMappingCoexistence rejects creation of a value-level +// subject mapping when the value's parent definition already has a dynamic value +// entitlement mapping. +func (c PolicyDBClient) ensureNoDynamicValueMappingCoexistence(ctx context.Context, attributeValueID string) error { + if attributeValueID == "" { + return nil + } + definitionID, err := c.queries.getAttributeDefinitionIDByValueID(ctx, attributeValueID) + if err != nil { + return db.WrapIfKnownInvalidQueryErr(err) + } + count, err := c.queries.countDynamicValueMappingsByDefinitionID(ctx, definitionID) + if err != nil { + return db.WrapIfKnownInvalidQueryErr(err) + } + if count > 0 { + return errors.Join(db.ErrRestrictViolation, + fmt.Errorf("attribute definition [%s] has a dynamic value mapping; it cannot also have value-level subject mappings", definitionID)) + } + return nil +} + +func (c PolicyDBClient) resolveDynamicValueMappingSubjectConditionSet( + ctx context.Context, + r *dynamicvaluemapping.CreateDynamicValueMappingRequest, + namespaceID string, +) (*policy.SubjectConditionSet, error) { + switch { + case r.GetExistingSubjectConditionSetId() != "": + scs, err := c.GetSubjectConditionSet(ctx, r.GetExistingSubjectConditionSetId()) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + return scs, nil + case r.GetNewSubjectConditionSet() != nil: + scs, err := c.CreateSubjectConditionSet(ctx, r.GetNewSubjectConditionSet(), namespaceID, "") + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + return scs, nil + default: + // The static pre-gate is optional; no SubjectConditionSet is a valid state. + return nil, nil //nolint:nilnil // optional pre-gate: nil SCS with nil error is intentional + } +} + +func (c PolicyDBClient) validateDynamicValueMappingNamespaceConsistency( + ctx context.Context, + targetNsID string, + attr *policy.Attribute, + actionIDs []string, + scs *policy.SubjectConditionSet, +) error { + if targetNsID != "" && attr.GetNamespace().GetId() != targetNsID { + return errors.Join(db.ErrNamespaceMismatch, + fmt.Errorf("attribute definition namespace [%s] does not match the specified dynamic value mapping namespace [%s]", attr.GetNamespace().GetId(), targetNsID)) + } + + if len(actionIDs) > 0 { + actionRows, err := c.queries.getActionsByIDs(ctx, actionIDs) + if err != nil { + return db.WrapIfKnownInvalidQueryErr(err) + } + for _, a := range actionRows { + actionNsID := UUIDToString(a.NamespaceID) + if actionNsID != targetNsID { + return errors.Join(db.ErrNamespaceMismatch, + fmt.Errorf("action [%s] namespace [%s] does not match the specified dynamic value mapping namespace [%s]", a.ID, actionNsID, targetNsID)) + } + } + } + + if scs != nil && scs.GetNamespace().GetId() != targetNsID { + return errors.Join(db.ErrNamespaceMismatch, + fmt.Errorf("subject condition set [%s] namespace [%s] does not match the specified dynamic value mapping namespace [%s]", scs.GetId(), scs.GetNamespace().GetId(), targetNsID)) + } + + return nil +} diff --git a/service/policy/db/dynamic_value_mappings.sql.go b/service/policy/db/dynamic_value_mappings.sql.go new file mode 100644 index 0000000000..29b0182186 --- /dev/null +++ b/service/policy/db/dynamic_value_mappings.sql.go @@ -0,0 +1,624 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dynamic_value_mappings.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countDynamicValueMappingsByDefinitionID = `-- name: countDynamicValueMappingsByDefinitionID :one +SELECT COUNT(id) +FROM dynamic_value_mappings +WHERE attribute_definition_id = $1 +` + +// Counts dynamic value entitlement mappings on the given definition. Used to enforce +// no-coexistence from the subject-mapping create path. +// +// SELECT COUNT(id) +// FROM dynamic_value_mappings +// WHERE attribute_definition_id = $1 +func (q *Queries) countDynamicValueMappingsByDefinitionID(ctx context.Context, attributeDefinitionID string) (int64, error) { + row := q.db.QueryRow(ctx, countDynamicValueMappingsByDefinitionID, attributeDefinitionID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countValueSubjectMappingsByDefinitionID = `-- name: countValueSubjectMappingsByDefinitionID :one +SELECT COUNT(sm.id) +FROM subject_mappings sm +JOIN attribute_values av ON sm.attribute_value_id = av.id +WHERE av.attribute_definition_id = $1 +` + +// Counts value-level subject mappings whose attribute value belongs to the given +// definition. Used to enforce no-coexistence with dynamic value entitlement mappings. +// +// SELECT COUNT(sm.id) +// FROM subject_mappings sm +// JOIN attribute_values av ON sm.attribute_value_id = av.id +// WHERE av.attribute_definition_id = $1 +func (q *Queries) countValueSubjectMappingsByDefinitionID(ctx context.Context, attributeDefinitionID string) (int64, error) { + row := q.db.QueryRow(ctx, countValueSubjectMappingsByDefinitionID, attributeDefinitionID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createDynamicValueMapping = `-- name: createDynamicValueMapping :one +WITH inserted_mapping AS ( + INSERT INTO dynamic_value_mappings ( + attribute_definition_id, + subject_external_selector_value, + comparison, + case_insensitive, + metadata, + subject_condition_set_id, + namespace_id + ) + VALUES ( + $1, + $2, + $3, + $4, + $5, + $6::uuid, + $7::uuid + ) + RETURNING id +), +inserted_actions AS ( + INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) + SELECT + (SELECT id FROM inserted_mapping), + unnest($8::uuid[]) +) +SELECT id FROM inserted_mapping +` + +type createDynamicValueMappingParams struct { + AttributeDefinitionID string `json:"attribute_definition_id"` + SubjectExternalSelectorValue string `json:"subject_external_selector_value"` + Comparison int16 `json:"comparison"` + CaseInsensitive bool `json:"case_insensitive"` + Metadata []byte `json:"metadata"` + SubjectConditionSetID pgtype.UUID `json:"subject_condition_set_id"` + NamespaceID pgtype.UUID `json:"namespace_id"` + ActionIds []string `json:"action_ids"` +} + +// createDynamicValueMapping +// +// WITH inserted_mapping AS ( +// INSERT INTO dynamic_value_mappings ( +// attribute_definition_id, +// subject_external_selector_value, +// comparison, +// case_insensitive, +// metadata, +// subject_condition_set_id, +// namespace_id +// ) +// VALUES ( +// $1, +// $2, +// $3, +// $4, +// $5, +// $6::uuid, +// $7::uuid +// ) +// RETURNING id +// ), +// inserted_actions AS ( +// INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) +// SELECT +// (SELECT id FROM inserted_mapping), +// unnest($8::uuid[]) +// ) +// SELECT id FROM inserted_mapping +func (q *Queries) createDynamicValueMapping(ctx context.Context, arg createDynamicValueMappingParams) (string, error) { + row := q.db.QueryRow(ctx, createDynamicValueMapping, + arg.AttributeDefinitionID, + arg.SubjectExternalSelectorValue, + arg.Comparison, + arg.CaseInsensitive, + arg.Metadata, + arg.SubjectConditionSetID, + arg.NamespaceID, + arg.ActionIds, + ) + var id string + err := row.Scan(&id) + return id, err +} + +const deleteDynamicValueMapping = `-- name: deleteDynamicValueMapping :execrows +DELETE FROM dynamic_value_mappings WHERE id = $1 +` + +// deleteDynamicValueMapping +// +// DELETE FROM dynamic_value_mappings WHERE id = $1 +func (q *Queries) deleteDynamicValueMapping(ctx context.Context, id string) (int64, error) { + result, err := q.db.Exec(ctx, deleteDynamicValueMapping, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getAttributeDefinitionIDByValueID = `-- name: getAttributeDefinitionIDByValueID :one +SELECT attribute_definition_id +FROM attribute_values +WHERE id = $1 +` + +// getAttributeDefinitionIDByValueID +// +// SELECT attribute_definition_id +// FROM attribute_values +// WHERE id = $1 +func (q *Queries) getAttributeDefinitionIDByValueID(ctx context.Context, id string) (string, error) { + row := q.db.QueryRow(ctx, getAttributeDefinitionIDByValueID, id) + var attribute_definition_id string + err := row.Scan(&attribute_definition_id) + return attribute_definition_id, err +} + +const getDynamicValueMapping = `-- name: getDynamicValueMapping :one +WITH mapping_actions AS ( + SELECT + dvm.action_id, + dvm.dynamic_value_mapping_id, + JSONB_BUILD_OBJECT( + 'id', a.id, + 'name', a.name, + 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL + ELSE JSONB_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) + END + ) AS action + FROM dynamic_value_mapping_actions dvm + JOIN actions a ON dvm.action_id = a.id + LEFT JOIN attribute_namespaces ans ON ans.id = a.namespace_id + LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL + WHERE dvm.dynamic_value_mapping_id = $1 +), +definition_actions AS ( + SELECT + dynamic_value_mapping_id, + COALESCE(JSONB_AGG(action), '[]'::JSONB) AS actions + FROM mapping_actions + GROUP BY dynamic_value_mapping_id +) +SELECT + dvem.id, + dvem.attribute_definition_id, + dvem.subject_external_selector_value, + dvem.comparison, + dvem.case_insensitive, + dvem.subject_condition_set_id, + COALESCE(da.actions, '[]'::JSONB) AS actions, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', dvem.metadata -> 'labels', 'created_at', dvem.created_at, 'updated_at', dvem.updated_at)) AS metadata, + CASE + WHEN dvem.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT('id', m_ns.id, 'name', m_ns.name, 'fqn', m_ns_fqns.fqn) + END AS namespace +FROM dynamic_value_mappings dvem +LEFT JOIN definition_actions da ON dvem.id = da.dynamic_value_mapping_id +LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id +LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL +WHERE dvem.id = $1 +` + +type getDynamicValueMappingRow struct { + ID string `json:"id"` + AttributeDefinitionID string `json:"attribute_definition_id"` + SubjectExternalSelectorValue string `json:"subject_external_selector_value"` + Comparison int16 `json:"comparison"` + CaseInsensitive bool `json:"case_insensitive"` + SubjectConditionSetID pgtype.UUID `json:"subject_condition_set_id"` + Actions interface{} `json:"actions"` + Metadata []byte `json:"metadata"` + Namespace interface{} `json:"namespace"` +} + +// getDynamicValueMapping +// +// WITH mapping_actions AS ( +// SELECT +// dvm.action_id, +// dvm.dynamic_value_mapping_id, +// JSONB_BUILD_OBJECT( +// 'id', a.id, +// 'name', a.name, +// 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL +// ELSE JSONB_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) +// END +// ) AS action +// FROM dynamic_value_mapping_actions dvm +// JOIN actions a ON dvm.action_id = a.id +// LEFT JOIN attribute_namespaces ans ON ans.id = a.namespace_id +// LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL +// WHERE dvm.dynamic_value_mapping_id = $1 +// ), +// definition_actions AS ( +// SELECT +// dynamic_value_mapping_id, +// COALESCE(JSONB_AGG(action), '[]'::JSONB) AS actions +// FROM mapping_actions +// GROUP BY dynamic_value_mapping_id +// ) +// SELECT +// dvem.id, +// dvem.attribute_definition_id, +// dvem.subject_external_selector_value, +// dvem.comparison, +// dvem.case_insensitive, +// dvem.subject_condition_set_id, +// COALESCE(da.actions, '[]'::JSONB) AS actions, +// JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', dvem.metadata -> 'labels', 'created_at', dvem.created_at, 'updated_at', dvem.updated_at)) AS metadata, +// CASE +// WHEN dvem.namespace_id IS NULL THEN NULL +// ELSE JSON_BUILD_OBJECT('id', m_ns.id, 'name', m_ns.name, 'fqn', m_ns_fqns.fqn) +// END AS namespace +// FROM dynamic_value_mappings dvem +// LEFT JOIN definition_actions da ON dvem.id = da.dynamic_value_mapping_id +// LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id +// LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL +// WHERE dvem.id = $1 +func (q *Queries) getDynamicValueMapping(ctx context.Context, id string) (getDynamicValueMappingRow, error) { + row := q.db.QueryRow(ctx, getDynamicValueMapping, id) + var i getDynamicValueMappingRow + err := row.Scan( + &i.ID, + &i.AttributeDefinitionID, + &i.SubjectExternalSelectorValue, + &i.Comparison, + &i.CaseInsensitive, + &i.SubjectConditionSetID, + &i.Actions, + &i.Metadata, + &i.Namespace, + ) + return i, err +} + +const listDynamicValueMappings = `-- name: listDynamicValueMappings :many + +WITH params AS ( + SELECT + COALESCE(NULLIF($6::text, ''), 'created_at') AS resolved_field, + COALESCE(NULLIF($7::text, ''), 'DESC') AS resolved_direction +), +mapping_actions AS ( + SELECT + dvm.action_id, + dvm.dynamic_value_mapping_id, + JSONB_BUILD_OBJECT( + 'id', a.id, + 'name', a.name, + 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL + ELSE JSONB_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) + END + ) AS action + FROM dynamic_value_mapping_actions dvm + JOIN actions a ON dvm.action_id = a.id + LEFT JOIN attribute_namespaces ans ON ans.id = a.namespace_id + LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL +), +definition_actions AS ( + SELECT + dynamic_value_mapping_id, + COALESCE(JSONB_AGG(action), '[]'::JSONB) AS actions + FROM mapping_actions + GROUP BY dynamic_value_mapping_id +), +counted AS ( + SELECT COUNT(dvem.id) AS total + FROM dynamic_value_mappings dvem + LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id + LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL + WHERE + ($1::uuid IS NULL OR dvem.namespace_id = $1::uuid) + AND ($2::text IS NULL OR m_ns_fqns.fqn = $2::text) + AND ($3::uuid IS NULL OR dvem.attribute_definition_id = $3::uuid) +) +SELECT + dvem.id, + dvem.attribute_definition_id, + dvem.subject_external_selector_value, + dvem.comparison, + dvem.case_insensitive, + dvem.subject_condition_set_id, + COALESCE(da.actions, '[]'::JSONB) AS actions, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', dvem.metadata -> 'labels', 'created_at', dvem.created_at, 'updated_at', dvem.updated_at)) AS metadata, + CASE + WHEN dvem.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT('id', m_ns.id, 'name', m_ns.name, 'fqn', m_ns_fqns.fqn) + END AS namespace, + counted.total +FROM dynamic_value_mappings dvem +CROSS JOIN counted +CROSS JOIN params p +LEFT JOIN definition_actions da ON dvem.id = da.dynamic_value_mapping_id +LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id +LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL +WHERE + ($1::uuid IS NULL OR dvem.namespace_id = $1::uuid) + AND ($2::text IS NULL OR m_ns_fqns.fqn = $2::text) + AND ($3::uuid IS NULL OR dvem.attribute_definition_id = $3::uuid) +GROUP BY + dvem.id, + da.actions, + dvem.metadata, dvem.created_at, dvem.updated_at, + m_ns.id, m_ns.name, m_ns_fqns.fqn, + counted.total, + p.resolved_field, p.resolved_direction +ORDER BY + CASE WHEN p.resolved_field = 'created_at' AND p.resolved_direction = 'ASC' THEN dvem.created_at END ASC, + CASE WHEN p.resolved_field = 'created_at' AND p.resolved_direction = 'DESC' THEN dvem.created_at END DESC, + CASE WHEN p.resolved_field = 'updated_at' AND p.resolved_direction = 'ASC' THEN dvem.updated_at END ASC, + CASE WHEN p.resolved_field = 'updated_at' AND p.resolved_direction = 'DESC' THEN dvem.updated_at END DESC, + dvem.id ASC +LIMIT $5 +OFFSET $4 +` + +type listDynamicValueMappingsParams struct { + NamespaceID pgtype.UUID `json:"namespace_id"` + NamespaceFqn pgtype.Text `json:"namespace_fqn"` + AttributeDefinitionID pgtype.UUID `json:"attribute_definition_id"` + Offset int32 `json:"offset_"` + Limit int32 `json:"limit_"` + SortField string `json:"sort_field"` + SortDirection string `json:"sort_direction"` +} + +type listDynamicValueMappingsRow struct { + ID string `json:"id"` + AttributeDefinitionID string `json:"attribute_definition_id"` + SubjectExternalSelectorValue string `json:"subject_external_selector_value"` + Comparison int16 `json:"comparison"` + CaseInsensitive bool `json:"case_insensitive"` + SubjectConditionSetID pgtype.UUID `json:"subject_condition_set_id"` + Actions interface{} `json:"actions"` + Metadata []byte `json:"metadata"` + Namespace interface{} `json:"namespace"` + Total int64 `json:"total"` +} + +// -------------------------------------------------------------- +// DEFINITION VALUE ENTITLEMENT MAPPINGS +// -------------------------------------------------------------- +// +// WITH params AS ( +// SELECT +// COALESCE(NULLIF($6::text, ''), 'created_at') AS resolved_field, +// COALESCE(NULLIF($7::text, ''), 'DESC') AS resolved_direction +// ), +// mapping_actions AS ( +// SELECT +// dvm.action_id, +// dvm.dynamic_value_mapping_id, +// JSONB_BUILD_OBJECT( +// 'id', a.id, +// 'name', a.name, +// 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL +// ELSE JSONB_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) +// END +// ) AS action +// FROM dynamic_value_mapping_actions dvm +// JOIN actions a ON dvm.action_id = a.id +// LEFT JOIN attribute_namespaces ans ON ans.id = a.namespace_id +// LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL +// ), +// definition_actions AS ( +// SELECT +// dynamic_value_mapping_id, +// COALESCE(JSONB_AGG(action), '[]'::JSONB) AS actions +// FROM mapping_actions +// GROUP BY dynamic_value_mapping_id +// ), +// counted AS ( +// SELECT COUNT(dvem.id) AS total +// FROM dynamic_value_mappings dvem +// LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id +// LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL +// WHERE +// ($1::uuid IS NULL OR dvem.namespace_id = $1::uuid) +// AND ($2::text IS NULL OR m_ns_fqns.fqn = $2::text) +// AND ($3::uuid IS NULL OR dvem.attribute_definition_id = $3::uuid) +// ) +// SELECT +// dvem.id, +// dvem.attribute_definition_id, +// dvem.subject_external_selector_value, +// dvem.comparison, +// dvem.case_insensitive, +// dvem.subject_condition_set_id, +// COALESCE(da.actions, '[]'::JSONB) AS actions, +// JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', dvem.metadata -> 'labels', 'created_at', dvem.created_at, 'updated_at', dvem.updated_at)) AS metadata, +// CASE +// WHEN dvem.namespace_id IS NULL THEN NULL +// ELSE JSON_BUILD_OBJECT('id', m_ns.id, 'name', m_ns.name, 'fqn', m_ns_fqns.fqn) +// END AS namespace, +// counted.total +// FROM dynamic_value_mappings dvem +// CROSS JOIN counted +// CROSS JOIN params p +// LEFT JOIN definition_actions da ON dvem.id = da.dynamic_value_mapping_id +// LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id +// LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL +// WHERE +// ($1::uuid IS NULL OR dvem.namespace_id = $1::uuid) +// AND ($2::text IS NULL OR m_ns_fqns.fqn = $2::text) +// AND ($3::uuid IS NULL OR dvem.attribute_definition_id = $3::uuid) +// GROUP BY +// dvem.id, +// da.actions, +// dvem.metadata, dvem.created_at, dvem.updated_at, +// m_ns.id, m_ns.name, m_ns_fqns.fqn, +// counted.total, +// p.resolved_field, p.resolved_direction +// ORDER BY +// CASE WHEN p.resolved_field = 'created_at' AND p.resolved_direction = 'ASC' THEN dvem.created_at END ASC, +// CASE WHEN p.resolved_field = 'created_at' AND p.resolved_direction = 'DESC' THEN dvem.created_at END DESC, +// CASE WHEN p.resolved_field = 'updated_at' AND p.resolved_direction = 'ASC' THEN dvem.updated_at END ASC, +// CASE WHEN p.resolved_field = 'updated_at' AND p.resolved_direction = 'DESC' THEN dvem.updated_at END DESC, +// dvem.id ASC +// LIMIT $5 +// OFFSET $4 +func (q *Queries) listDynamicValueMappings(ctx context.Context, arg listDynamicValueMappingsParams) ([]listDynamicValueMappingsRow, error) { + rows, err := q.db.Query(ctx, listDynamicValueMappings, + arg.NamespaceID, + arg.NamespaceFqn, + arg.AttributeDefinitionID, + arg.Offset, + arg.Limit, + arg.SortField, + arg.SortDirection, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []listDynamicValueMappingsRow + for rows.Next() { + var i listDynamicValueMappingsRow + if err := rows.Scan( + &i.ID, + &i.AttributeDefinitionID, + &i.SubjectExternalSelectorValue, + &i.Comparison, + &i.CaseInsensitive, + &i.SubjectConditionSetID, + &i.Actions, + &i.Metadata, + &i.Namespace, + &i.Total, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateDynamicValueMapping = `-- name: updateDynamicValueMapping :execrows +WITH + mapping_update AS ( + UPDATE dynamic_value_mappings + SET + metadata = COALESCE($1::JSONB, metadata), + subject_external_selector_value = COALESCE($2::TEXT, subject_external_selector_value), + comparison = COALESCE($3::SMALLINT, comparison), + case_insensitive = COALESCE($4::BOOLEAN, case_insensitive), + subject_condition_set_id = COALESCE($5::UUID, subject_condition_set_id) + WHERE id = $6 + RETURNING id + ), + action_delete AS ( + DELETE FROM dynamic_value_mapping_actions + WHERE + dynamic_value_mapping_id = $6 + AND $7::UUID[] IS NOT NULL + AND action_id NOT IN (SELECT unnest($7::UUID[])) + ), + action_insert AS ( + INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) + SELECT + $6, + a + FROM unnest($7::UUID[]) AS a + WHERE + $7::UUID[] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM dynamic_value_mapping_actions + WHERE dynamic_value_mapping_id = $6 AND action_id = a + ) + ), + update_count AS ( + SELECT COUNT(*) AS cnt + FROM mapping_update + ) +SELECT cnt +FROM update_count +` + +type updateDynamicValueMappingParams struct { + Metadata []byte `json:"metadata"` + SubjectExternalSelectorValue pgtype.Text `json:"subject_external_selector_value"` + Comparison pgtype.Int2 `json:"comparison"` + CaseInsensitive pgtype.Bool `json:"case_insensitive"` + SubjectConditionSetID pgtype.UUID `json:"subject_condition_set_id"` + ID string `json:"id"` + ActionIds []string `json:"action_ids"` +} + +// updateDynamicValueMapping +// +// WITH +// mapping_update AS ( +// UPDATE dynamic_value_mappings +// SET +// metadata = COALESCE($1::JSONB, metadata), +// subject_external_selector_value = COALESCE($2::TEXT, subject_external_selector_value), +// comparison = COALESCE($3::SMALLINT, comparison), +// case_insensitive = COALESCE($4::BOOLEAN, case_insensitive), +// subject_condition_set_id = COALESCE($5::UUID, subject_condition_set_id) +// WHERE id = $6 +// RETURNING id +// ), +// action_delete AS ( +// DELETE FROM dynamic_value_mapping_actions +// WHERE +// dynamic_value_mapping_id = $6 +// AND $7::UUID[] IS NOT NULL +// AND action_id NOT IN (SELECT unnest($7::UUID[])) +// ), +// action_insert AS ( +// INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) +// SELECT +// $6, +// a +// FROM unnest($7::UUID[]) AS a +// WHERE +// $7::UUID[] IS NOT NULL +// AND NOT EXISTS ( +// SELECT 1 +// FROM dynamic_value_mapping_actions +// WHERE dynamic_value_mapping_id = $6 AND action_id = a +// ) +// ), +// update_count AS ( +// SELECT COUNT(*) AS cnt +// FROM mapping_update +// ) +// SELECT cnt +// FROM update_count +func (q *Queries) updateDynamicValueMapping(ctx context.Context, arg updateDynamicValueMappingParams) (int64, error) { + result, err := q.db.Exec(ctx, updateDynamicValueMapping, + arg.Metadata, + arg.SubjectExternalSelectorValue, + arg.Comparison, + arg.CaseInsensitive, + arg.SubjectConditionSetID, + arg.ID, + arg.ActionIds, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/service/policy/db/migrations/20260618000000_add_dynamic_value_mappings.sql b/service/policy/db/migrations/20260618000000_add_dynamic_value_mappings.sql new file mode 100644 index 0000000000..709e9f5aec --- /dev/null +++ b/service/policy/db/migrations/20260618000000_add_dynamic_value_mappings.sql @@ -0,0 +1,63 @@ +-- +goose Up +-- +goose StatementBegin + +-- Dynamic Value Mappings raise entitlement authority from a concrete +-- attribute value to the attribute definition. A single mapping resolves entitlement for +-- dynamically-requested values under the definition by comparing the requested resource +-- value segment against the entity representation (the value_resolver), optionally gated +-- by a static SubjectConditionSet. +CREATE TABLE IF NOT EXISTS dynamic_value_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + attribute_definition_id UUID NOT NULL REFERENCES attribute_definitions(id) ON DELETE CASCADE, + -- value_resolver: selector against the flattened entity representation + comparison operator + subject_external_selector_value TEXT NOT NULL, + comparison SMALLINT NOT NULL, + case_insensitive BOOLEAN NOT NULL DEFAULT FALSE, + -- optional static pre-gate, evaluated with normal SubjectConditionSet semantics + subject_condition_set_id UUID REFERENCES subject_condition_set(id) ON DELETE CASCADE, + namespace_id UUID REFERENCES attribute_namespaces(id) ON DELETE CASCADE, + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE dynamic_value_mappings IS 'Definition-scoped dynamic value entitlement mappings (DSPX-2754)'; +COMMENT ON COLUMN dynamic_value_mappings.subject_external_selector_value IS 'Selector resolved against the entity representation, compared to the requested resource value segment'; +COMMENT ON COLUMN dynamic_value_mappings.comparison IS 'policy.ConditionComparisonOperatorEnum value'; +COMMENT ON COLUMN dynamic_value_mappings.case_insensitive IS 'When true, the comparison is case-insensitive'; + +CREATE TRIGGER dynamic_value_mappings_updated_at + BEFORE UPDATE ON dynamic_value_mappings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +CREATE TABLE IF NOT EXISTS dynamic_value_mapping_actions ( + dynamic_value_mapping_id UUID NOT NULL REFERENCES dynamic_value_mappings(id) ON DELETE CASCADE, + action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE, + PRIMARY KEY (dynamic_value_mapping_id, action_id) +); + +CREATE INDEX idx_dynamic_value_mappings_definition_id + ON dynamic_value_mappings(attribute_definition_id); +CREATE INDEX idx_dynamic_value_mappings_scs_id + ON dynamic_value_mappings(subject_condition_set_id); +CREATE INDEX idx_dynamic_value_mappings_namespace_id + ON dynamic_value_mappings(namespace_id); +-- No separate index on dynamic_value_mapping_actions: its composite +-- PRIMARY KEY (dynamic_value_mapping_id, action_id) already covers lookups. + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP INDEX IF EXISTS idx_dynamic_value_mappings_namespace_id; +DROP INDEX IF EXISTS idx_dynamic_value_mappings_scs_id; +DROP INDEX IF EXISTS idx_dynamic_value_mappings_definition_id; + +DROP TABLE IF EXISTS dynamic_value_mapping_actions; + +DROP TRIGGER IF EXISTS dynamic_value_mappings_updated_at ON dynamic_value_mappings; +DROP TABLE IF EXISTS dynamic_value_mappings; + +-- +goose StatementEnd diff --git a/service/policy/db/models.go b/service/policy/db/models.go index 840081206f..015708a2b7 100644 --- a/service/policy/db/models.go +++ b/service/policy/db/models.go @@ -234,6 +234,28 @@ type BaseKey struct { KeyAccessServerKeyID pgtype.UUID `json:"key_access_server_key_id"` } +// Definition-scoped dynamic value entitlement mappings (DSPX-2754) +type DynamicValueMapping struct { + ID string `json:"id"` + AttributeDefinitionID string `json:"attribute_definition_id"` + // Selector resolved against the entity representation, compared to the requested resource value segment + SubjectExternalSelectorValue string `json:"subject_external_selector_value"` + // policy.ConditionComparisonOperatorEnum value + Comparison int16 `json:"comparison"` + // When true, the comparison is case-insensitive + CaseInsensitive bool `json:"case_insensitive"` + SubjectConditionSetID pgtype.UUID `json:"subject_condition_set_id"` + NamespaceID pgtype.UUID `json:"namespace_id"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type DynamicValueMappingAction struct { + DynamicValueMappingID string `json:"dynamic_value_mapping_id"` + ActionID string `json:"action_id"` +} + // Table to store the known registrations of key access servers (KASs) type KeyAccessServer struct { // Primary key for the table diff --git a/service/policy/db/queries/dynamic_value_mappings.sql b/service/policy/db/queries/dynamic_value_mappings.sql new file mode 100644 index 0000000000..832fd51d3c --- /dev/null +++ b/service/policy/db/queries/dynamic_value_mappings.sql @@ -0,0 +1,220 @@ +---------------------------------------------------------------- +-- DEFINITION VALUE ENTITLEMENT MAPPINGS +---------------------------------------------------------------- + +-- name: listDynamicValueMappings :many +WITH params AS ( + SELECT + COALESCE(NULLIF(@sort_field::text, ''), 'created_at') AS resolved_field, + COALESCE(NULLIF(@sort_direction::text, ''), 'DESC') AS resolved_direction +), +mapping_actions AS ( + SELECT + dvm.action_id, + dvm.dynamic_value_mapping_id, + JSONB_BUILD_OBJECT( + 'id', a.id, + 'name', a.name, + 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL + ELSE JSONB_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) + END + ) AS action + FROM dynamic_value_mapping_actions dvm + JOIN actions a ON dvm.action_id = a.id + LEFT JOIN attribute_namespaces ans ON ans.id = a.namespace_id + LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL +), +definition_actions AS ( + SELECT + dynamic_value_mapping_id, + COALESCE(JSONB_AGG(action), '[]'::JSONB) AS actions + FROM mapping_actions + GROUP BY dynamic_value_mapping_id +), +counted AS ( + SELECT COUNT(dvem.id) AS total + FROM dynamic_value_mappings dvem + LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id + LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL + WHERE + (sqlc.narg('namespace_id')::uuid IS NULL OR dvem.namespace_id = sqlc.narg('namespace_id')::uuid) + AND (sqlc.narg('namespace_fqn')::text IS NULL OR m_ns_fqns.fqn = sqlc.narg('namespace_fqn')::text) + AND (sqlc.narg('attribute_definition_id')::uuid IS NULL OR dvem.attribute_definition_id = sqlc.narg('attribute_definition_id')::uuid) +) +SELECT + dvem.id, + dvem.attribute_definition_id, + dvem.subject_external_selector_value, + dvem.comparison, + dvem.case_insensitive, + dvem.subject_condition_set_id, + COALESCE(da.actions, '[]'::JSONB) AS actions, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', dvem.metadata -> 'labels', 'created_at', dvem.created_at, 'updated_at', dvem.updated_at)) AS metadata, + CASE + WHEN dvem.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT('id', m_ns.id, 'name', m_ns.name, 'fqn', m_ns_fqns.fqn) + END AS namespace, + counted.total +FROM dynamic_value_mappings dvem +CROSS JOIN counted +CROSS JOIN params p +LEFT JOIN definition_actions da ON dvem.id = da.dynamic_value_mapping_id +LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id +LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL +WHERE + (sqlc.narg('namespace_id')::uuid IS NULL OR dvem.namespace_id = sqlc.narg('namespace_id')::uuid) + AND (sqlc.narg('namespace_fqn')::text IS NULL OR m_ns_fqns.fqn = sqlc.narg('namespace_fqn')::text) + AND (sqlc.narg('attribute_definition_id')::uuid IS NULL OR dvem.attribute_definition_id = sqlc.narg('attribute_definition_id')::uuid) +GROUP BY + dvem.id, + da.actions, + dvem.metadata, dvem.created_at, dvem.updated_at, + m_ns.id, m_ns.name, m_ns_fqns.fqn, + counted.total, + p.resolved_field, p.resolved_direction +ORDER BY + CASE WHEN p.resolved_field = 'created_at' AND p.resolved_direction = 'ASC' THEN dvem.created_at END ASC, + CASE WHEN p.resolved_field = 'created_at' AND p.resolved_direction = 'DESC' THEN dvem.created_at END DESC, + CASE WHEN p.resolved_field = 'updated_at' AND p.resolved_direction = 'ASC' THEN dvem.updated_at END ASC, + CASE WHEN p.resolved_field = 'updated_at' AND p.resolved_direction = 'DESC' THEN dvem.updated_at END DESC, + dvem.id ASC +LIMIT @limit_ +OFFSET @offset_; + +-- name: getDynamicValueMapping :one +WITH mapping_actions AS ( + SELECT + dvm.action_id, + dvm.dynamic_value_mapping_id, + JSONB_BUILD_OBJECT( + 'id', a.id, + 'name', a.name, + 'namespace', CASE WHEN a.namespace_id IS NULL THEN NULL + ELSE JSONB_BUILD_OBJECT('id', ans.id, 'name', ans.name, 'fqn', ans_fqns.fqn) + END + ) AS action + FROM dynamic_value_mapping_actions dvm + JOIN actions a ON dvm.action_id = a.id + LEFT JOIN attribute_namespaces ans ON ans.id = a.namespace_id + LEFT JOIN attribute_fqns ans_fqns ON ans_fqns.namespace_id = ans.id AND ans_fqns.attribute_id IS NULL AND ans_fqns.value_id IS NULL + WHERE dvm.dynamic_value_mapping_id = @id +), +definition_actions AS ( + SELECT + dynamic_value_mapping_id, + COALESCE(JSONB_AGG(action), '[]'::JSONB) AS actions + FROM mapping_actions + GROUP BY dynamic_value_mapping_id +) +SELECT + dvem.id, + dvem.attribute_definition_id, + dvem.subject_external_selector_value, + dvem.comparison, + dvem.case_insensitive, + dvem.subject_condition_set_id, + COALESCE(da.actions, '[]'::JSONB) AS actions, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', dvem.metadata -> 'labels', 'created_at', dvem.created_at, 'updated_at', dvem.updated_at)) AS metadata, + CASE + WHEN dvem.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT('id', m_ns.id, 'name', m_ns.name, 'fqn', m_ns_fqns.fqn) + END AS namespace +FROM dynamic_value_mappings dvem +LEFT JOIN definition_actions da ON dvem.id = da.dynamic_value_mapping_id +LEFT JOIN attribute_namespaces m_ns ON m_ns.id = dvem.namespace_id +LEFT JOIN attribute_fqns m_ns_fqns ON m_ns_fqns.namespace_id = m_ns.id AND m_ns_fqns.attribute_id IS NULL AND m_ns_fqns.value_id IS NULL +WHERE dvem.id = @id; + +-- name: createDynamicValueMapping :one +WITH inserted_mapping AS ( + INSERT INTO dynamic_value_mappings ( + attribute_definition_id, + subject_external_selector_value, + comparison, + case_insensitive, + metadata, + subject_condition_set_id, + namespace_id + ) + VALUES ( + @attribute_definition_id, + @subject_external_selector_value, + @comparison, + @case_insensitive, + @metadata, + sqlc.narg('subject_condition_set_id')::uuid, + sqlc.narg('namespace_id')::uuid + ) + RETURNING id +), +inserted_actions AS ( + INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) + SELECT + (SELECT id FROM inserted_mapping), + unnest(sqlc.arg('action_ids')::uuid[]) +) +SELECT id FROM inserted_mapping; + +-- name: updateDynamicValueMapping :execrows +WITH + mapping_update AS ( + UPDATE dynamic_value_mappings + SET + metadata = COALESCE(sqlc.narg('metadata')::JSONB, metadata), + subject_external_selector_value = COALESCE(sqlc.narg('subject_external_selector_value')::TEXT, subject_external_selector_value), + comparison = COALESCE(sqlc.narg('comparison')::SMALLINT, comparison), + case_insensitive = COALESCE(sqlc.narg('case_insensitive')::BOOLEAN, case_insensitive), + subject_condition_set_id = COALESCE(sqlc.narg('subject_condition_set_id')::UUID, subject_condition_set_id) + WHERE id = sqlc.arg('id') + RETURNING id + ), + action_delete AS ( + DELETE FROM dynamic_value_mapping_actions + WHERE + dynamic_value_mapping_id = sqlc.arg('id') + AND sqlc.narg('action_ids')::UUID[] IS NOT NULL + AND action_id NOT IN (SELECT unnest(sqlc.narg('action_ids')::UUID[])) + ), + action_insert AS ( + INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) + SELECT + sqlc.arg('id'), + a + FROM unnest(sqlc.narg('action_ids')::UUID[]) AS a + WHERE + sqlc.narg('action_ids')::UUID[] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM dynamic_value_mapping_actions + WHERE dynamic_value_mapping_id = sqlc.arg('id') AND action_id = a + ) + ), + update_count AS ( + SELECT COUNT(*) AS cnt + FROM mapping_update + ) +SELECT cnt +FROM update_count; + +-- name: deleteDynamicValueMapping :execrows +DELETE FROM dynamic_value_mappings WHERE id = $1; + +-- name: countValueSubjectMappingsByDefinitionID :one +-- Counts value-level subject mappings whose attribute value belongs to the given +-- definition. Used to enforce no-coexistence with dynamic value entitlement mappings. +SELECT COUNT(sm.id) +FROM subject_mappings sm +JOIN attribute_values av ON sm.attribute_value_id = av.id +WHERE av.attribute_definition_id = $1; + +-- name: countDynamicValueMappingsByDefinitionID :one +-- Counts dynamic value entitlement mappings on the given definition. Used to enforce +-- no-coexistence from the subject-mapping create path. +SELECT COUNT(id) +FROM dynamic_value_mappings +WHERE attribute_definition_id = $1; + +-- name: getAttributeDefinitionIDByValueID :one +SELECT attribute_definition_id +FROM attribute_values +WHERE id = $1; diff --git a/service/policy/db/subject_mappings.go b/service/policy/db/subject_mappings.go index 04fd2fb45c..1f928c6522 100644 --- a/service/policy/db/subject_mappings.go +++ b/service/policy/db/subject_mappings.go @@ -266,6 +266,13 @@ func (c PolicyDBClient) DeleteAllUnmappedSubjectConditionSets(ctx context.Contex // If a new subject condition set is provided, it will be created. The existing subject condition set id takes precedence. func (c PolicyDBClient) CreateSubjectMapping(ctx context.Context, s *subjectmapping.CreateSubjectMappingRequest) (*policy.SubjectMapping, error) { attributeValueID := s.GetAttributeValueId() + + // Enforce no-coexistence: a value-level subject mapping cannot be created on a + // definition that already has a dynamic value entitlement mapping (DSPX-2754 / ADR 0005). + if err := c.ensureNoDynamicValueMappingCoexistence(ctx, attributeValueID); err != nil { + return nil, err + } + resolvedNamespaceID, err := c.resolveNamespace(ctx, s.GetNamespaceId(), s.GetNamespaceFqn()) if err != nil { return nil, err diff --git a/service/policy/db/utils.go b/service/policy/db/utils.go index 2b56f384d8..d644351714 100644 --- a/service/policy/db/utils.go +++ b/service/policy/db/utils.go @@ -11,6 +11,7 @@ import ( "github.com/opentdf/platform/protocol/go/common" "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/dynamicvaluemapping" "github.com/opentdf/platform/protocol/go/policy/kasregistry" "github.com/opentdf/platform/protocol/go/policy/namespaces" "github.com/opentdf/platform/protocol/go/policy/obligations" @@ -425,6 +426,26 @@ func GetSubjectMappingsSortParams(sort []*subjectmapping.SubjectMappingsSort) (s return getSubjectMappingsSortField(sort[0].GetField()), getSortDirection(sort[0].GetDirection()) } +func getDynamicValueMappingsSortField(field dynamicvaluemapping.SortDynamicValueMappingsType) string { + switch field { + case dynamicvaluemapping.SortDynamicValueMappingsType_SORT_DYNAMIC_VALUE_MAPPINGS_TYPE_CREATED_AT: + return sortFieldCreatedAt + case dynamicvaluemapping.SortDynamicValueMappingsType_SORT_DYNAMIC_VALUE_MAPPINGS_TYPE_UPDATED_AT: + return sortFieldUpdatedAt + case dynamicvaluemapping.SortDynamicValueMappingsType_SORT_DYNAMIC_VALUE_MAPPINGS_TYPE_UNSPECIFIED: + fallthrough + default: + return "" + } +} + +func GetDynamicValueMappingsSortParams(sort []*dynamicvaluemapping.DynamicValueMappingsSort) (string, string) { + if len(sort) == 0 { + return "", "" + } + return getDynamicValueMappingsSortField(sort[0].GetField()), getSortDirection(sort[0].GetDirection()) +} + func UUIDToString(uuid pgtype.UUID) string { if !uuid.Valid { return "" diff --git a/service/policy/dynamicvaluemapping/dynamic_value_mapping.go b/service/policy/dynamicvaluemapping/dynamic_value_mapping.go new file mode 100644 index 0000000000..3166f65462 --- /dev/null +++ b/service/policy/dynamicvaluemapping/dynamic_value_mapping.go @@ -0,0 +1,189 @@ +package dynamicvaluemapping + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "connectrpc.com/connect" + dvm "github.com/opentdf/platform/protocol/go/policy/dynamicvaluemapping" + "github.com/opentdf/platform/protocol/go/policy/dynamicvaluemapping/dynamicvaluemappingconnect" + "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/logger/audit" + "github.com/opentdf/platform/service/pkg/config" + "github.com/opentdf/platform/service/pkg/db" + "github.com/opentdf/platform/service/pkg/serviceregistry" + policyconfig "github.com/opentdf/platform/service/policy/config" + policydb "github.com/opentdf/platform/service/policy/db" +) + +type DynamicValueMappingService struct { //nolint:revive // descriptive name mirrors the policy object + dbClient policydb.PolicyDBClient + logger *logger.Logger + config *policyconfig.Config +} + +func OnConfigUpdate(svc *DynamicValueMappingService) serviceregistry.OnConfigUpdateHook { + return func(_ context.Context, cfg config.ServiceConfig) error { + sharedCfg, err := policyconfig.GetSharedPolicyConfig(cfg) + if err != nil { + return fmt.Errorf("failed to get shared policy config: %w", err) + } + svc.config = sharedCfg + svc.dbClient = policydb.NewClient(svc.dbClient.Client, svc.logger, int32(sharedCfg.ListRequestLimitMax), int32(sharedCfg.ListRequestLimitDefault)) + svc.logger.Info("dynamic value mapping service config reloaded") + return nil + } +} + +func NewRegistration(ns string, dbRegister serviceregistry.DBRegister) *serviceregistry.Service[dynamicvaluemappingconnect.DynamicValueMappingServiceHandler] { + svc := new(DynamicValueMappingService) + onUpdateConfigHook := OnConfigUpdate(svc) + + return &serviceregistry.Service[dynamicvaluemappingconnect.DynamicValueMappingServiceHandler]{ + Close: svc.Close, + ServiceOptions: serviceregistry.ServiceOptions[dynamicvaluemappingconnect.DynamicValueMappingServiceHandler]{ + Namespace: ns, + DB: dbRegister, + ServiceDesc: &dvm.DynamicValueMappingService_ServiceDesc, + ConnectRPCFunc: dynamicvaluemappingconnect.NewDynamicValueMappingServiceHandler, + OnConfigUpdate: onUpdateConfigHook, + RegisterFunc: func(srp serviceregistry.RegistrationParams) (dynamicvaluemappingconnect.DynamicValueMappingServiceHandler, serviceregistry.HandlerServer) { + logger := srp.Logger + cfg, err := policyconfig.GetSharedPolicyConfig(srp.Config) + if err != nil { + logger.Error("error getting dynamic value mapping service policy config", slog.String("error", err.Error())) + panic(err) + } + + svc.logger = logger + svc.dbClient = policydb.NewClient(srp.DBClient, logger, int32(cfg.ListRequestLimitMax), int32(cfg.ListRequestLimitDefault)) + svc.config = cfg + return svc, nil + }, + }, + } +} + +// Close gracefully shuts down the service, closing the database client. +func (s *DynamicValueMappingService) Close() { + s.logger.Info("gracefully shutting down dynamic value mapping service") + s.dbClient.Close() +} + +func (s DynamicValueMappingService) CreateDynamicValueMapping(ctx context.Context, + req *connect.Request[dvm.CreateDynamicValueMappingRequest], +) (*connect.Response[dvm.CreateDynamicValueMappingResponse], error) { + rsp := &dvm.CreateDynamicValueMappingResponse{} + s.logger.DebugContext(ctx, "creating dynamic value mapping") + if s.config.NamespacedPolicy && req.Msg.GetNamespaceId() == "" && req.Msg.GetNamespaceFqn() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("either namespace_id or namespace_fqn must be provided")) + } + + auditParams := audit.PolicyEventParams{ + ActionType: audit.ActionTypeCreate, + ObjectType: audit.ObjectTypeDynamicValueMapping, + } + + // Creation may involve action or SubjectConditionSet creation, so use a transaction. + err := s.dbClient.RunInTx(ctx, func(txClient *policydb.PolicyDBClient) error { + mapping, err := txClient.CreateDynamicValueMapping(ctx, req.Msg) + if err != nil { + s.logger.Audit.PolicyCRUDFailure(ctx, auditParams) + return err + } + + auditParams.ObjectID = mapping.GetId() + auditParams.Original = mapping + s.logger.Audit.PolicyCRUDSuccess(ctx, auditParams) + + rsp.DynamicValueMapping = mapping + return nil + }) + if err != nil { + return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextCreationFailed, slog.String("dynamicValueMapping", req.Msg.String())) + } + return connect.NewResponse(rsp), nil +} + +func (s DynamicValueMappingService) ListDynamicValueMappings(ctx context.Context, + req *connect.Request[dvm.ListDynamicValueMappingsRequest], +) (*connect.Response[dvm.ListDynamicValueMappingsResponse], error) { + s.logger.DebugContext(ctx, "listing dynamic value mappings") + + rsp, err := s.dbClient.ListDynamicValueMappings(ctx, req.Msg) + if err != nil { + return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextListRetrievalFailed) + } + return connect.NewResponse(rsp), nil +} + +func (s DynamicValueMappingService) GetDynamicValueMapping(ctx context.Context, + req *connect.Request[dvm.GetDynamicValueMappingRequest], +) (*connect.Response[dvm.GetDynamicValueMappingResponse], error) { + s.logger.DebugContext(ctx, "getting dynamic value mapping", slog.String("id", req.Msg.GetId())) + + mapping, err := s.dbClient.GetDynamicValueMapping(ctx, req.Msg.GetId()) + if err != nil { + return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextGetRetrievalFailed, slog.String("id", req.Msg.GetId())) + } + return connect.NewResponse(&dvm.GetDynamicValueMappingResponse{DynamicValueMapping: mapping}), nil +} + +func (s DynamicValueMappingService) UpdateDynamicValueMapping(ctx context.Context, + req *connect.Request[dvm.UpdateDynamicValueMappingRequest], +) (*connect.Response[dvm.UpdateDynamicValueMappingResponse], error) { + rsp := &dvm.UpdateDynamicValueMappingResponse{} + id := req.Msg.GetId() + s.logger.DebugContext(ctx, "updating dynamic value mapping", slog.String("id", id)) + + auditParams := audit.PolicyEventParams{ + ActionType: audit.ActionTypeUpdate, + ObjectType: audit.ObjectTypeDynamicValueMapping, + ObjectID: id, + } + + original, err := s.dbClient.GetDynamicValueMapping(ctx, id) + if err != nil { + s.logger.Audit.PolicyCRUDFailure(ctx, auditParams) + return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextGetRetrievalFailed, slog.String("id", id)) + } + + updated, err := s.dbClient.UpdateDynamicValueMapping(ctx, req.Msg) + if err != nil { + s.logger.Audit.PolicyCRUDFailure(ctx, auditParams) + return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextUpdateFailed, slog.String("id", id), slog.String("dynamicValueMapping", req.Msg.String())) + } + + auditParams.Original = original + auditParams.Updated = updated + s.logger.Audit.PolicyCRUDSuccess(ctx, auditParams) + + rsp.DynamicValueMapping = updated + return connect.NewResponse(rsp), nil +} + +func (s DynamicValueMappingService) DeleteDynamicValueMapping(ctx context.Context, + req *connect.Request[dvm.DeleteDynamicValueMappingRequest], +) (*connect.Response[dvm.DeleteDynamicValueMappingResponse], error) { + rsp := &dvm.DeleteDynamicValueMappingResponse{} + id := req.Msg.GetId() + s.logger.DebugContext(ctx, "deleting dynamic value mapping", slog.String("id", id)) + + auditParams := audit.PolicyEventParams{ + ActionType: audit.ActionTypeDelete, + ObjectType: audit.ObjectTypeDynamicValueMapping, + ObjectID: id, + } + + deleted, err := s.dbClient.DeleteDynamicValueMapping(ctx, id) + if err != nil { + s.logger.Audit.PolicyCRUDFailure(ctx, auditParams) + return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextDeletionFailed, slog.String("id", id)) + } + + s.logger.Audit.PolicyCRUDSuccess(ctx, auditParams) + rsp.DynamicValueMapping = deleted + return connect.NewResponse(rsp), nil +} diff --git a/service/policy/policy.go b/service/policy/policy.go index 4e1f479454..17273dbabf 100644 --- a/service/policy/policy.go +++ b/service/policy/policy.go @@ -7,6 +7,7 @@ import ( "github.com/opentdf/platform/service/policy/actions" "github.com/opentdf/platform/service/policy/attributes" "github.com/opentdf/platform/service/policy/db/migrations" + "github.com/opentdf/platform/service/policy/dynamicvaluemapping" "github.com/opentdf/platform/service/policy/kasregistry" "github.com/opentdf/platform/service/policy/keymanagement" "github.com/opentdf/platform/service/policy/namespaces" @@ -36,6 +37,7 @@ func NewRegistrations() []serviceregistry.IService { namespaces.NewRegistration(namespace, dbRegister), resourcemapping.NewRegistration(namespace, dbRegister), subjectmapping.NewRegistration(namespace, dbRegister), + dynamicvaluemapping.NewRegistration(namespace, dbRegister), kasregistry.NewRegistration(namespace, dbRegister), unsafe.NewRegistration(namespace, dbRegister), actions.NewRegistration(namespace, dbRegister), From 1d130bb4570dfd818ea3fca68d28c6a78f29dc48 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Thu, 18 Jun 2026 17:19:37 -0400 Subject: [PATCH 2/5] fix(policy): DSPX-2754 fix service count test and harden dynamic mapping indexing - Bump numExpectedPolicyServices to 11 now that DynamicValueMappingService is registered (fixes the go (service) CI failure). - PDP: skip HIERARCHY attribute definitions when indexing dynamic value mappings, using the canonical definition map (defense in depth vs an unset payload rule). - Add a multi-SubjectSet static-gate test to lock the AND aggregation across subject sets. Signed-off-by: Krish Suchak --- service/internal/access/v2/pdp.go | 14 +++++- .../dynamic_value_mapping_builtin_test.go | 47 +++++++++++++++++++ service/pkg/server/services_test.go | 2 +- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index cb801bad0b..fcd533fa7b 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -211,7 +211,8 @@ func NewPolicyDecisionPointWithDynamicValueMappings( } definitionFQN := mapping.GetAttributeDefinition().GetFqn() - if _, ok := allAttributesByDefinitionFQN[definitionFQN]; !ok { + canonicalDef, ok := allAttributesByDefinitionFQN[definitionFQN] + if !ok { l.WarnContext(ctx, "dynamic value mapping references unknown attribute definition - skipping", slog.String("dynamic_value_mapping_id", mapping.GetId()), @@ -220,6 +221,17 @@ func NewPolicyDecisionPointWithDynamicValueMappings( continue } + // Defense in depth alongside validateDynamicValueMapping: the mapping's own definition may + // carry an unset rule, so reject HIERARCHY using the canonical definition. + if canonicalDef.GetRule() == policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY { + l.WarnContext(ctx, + "dynamic value mapping references HIERARCHY attribute definition - skipping", + slog.String("dynamic_value_mapping_id", mapping.GetId()), + slog.String("attribute_definition_fqn", definitionFQN), + ) + continue + } + dynamicMappingsByDefinitionFQN[definitionFQN] = append(dynamicMappingsByDefinitionFQN[definitionFQN], mapping) } diff --git a/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go index d43bb8fb38..d9c1ebe5a0 100644 --- a/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go +++ b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go @@ -159,6 +159,53 @@ func TestEvaluateDynamicValueMappings_StaticGate(t *testing.T) { assert.Empty(t, got[valueFQN]) } +// TestEvaluateDynamicValueMappings_StaticGate_MultipleSubjectSets locks the AND aggregation +// across multiple SubjectSets in the optional static pre-gate: every subject set must pass for +// the gate (and therefore the mapping) to entitle. +func TestEvaluateDynamicValueMappings_StaticGate_MultipleSubjectSets(t *testing.T) { + const def = "https://hospital.co/attr/mrn" + const valueFQN = "https://hospital.co/attr/mrn/value/mrn-123" + + subjectSet := func(selector, value string) *policy.SubjectSet { + return &policy.SubjectSet{ + ConditionGroups: []*policy.ConditionGroup{{ + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + Conditions: []*policy.Condition{{ + SubjectExternalSelectorValue: selector, + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{value}, + }}, + }}, + } + } + scs := &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{ + subjectSet(".department", "cardiology"), + subjectSet(".role", "provider"), + }, + } + mapping := dvemMapping(def, ".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, false, scs, "read") + byDef := DynamicValueMappingsByDefinitionFQN{def: {mapping}} + + // both subject sets satisfied + resolver match -> entitled + got, err := EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(def, valueFQN, "mrn-123"), dvemEntityRep(t, map[string]interface{}{ + "department": "cardiology", + "role": "provider", + "patientAssignments": []interface{}{"mrn-123"}, + }), slog.Default()) + require.NoError(t, err) + assert.Equal(t, []string{"read"}, dvemActionNames(got[valueFQN])) + + // second subject set unsatisfied -> gate fails (AND) -> no entitlement + got, err = EvaluateDynamicValueMappingsWithActions(byDef, dvemDecisionable(def, valueFQN, "mrn-123"), dvemEntityRep(t, map[string]interface{}{ + "department": "cardiology", + "role": "nurse", + "patientAssignments": []interface{}{"mrn-123"}, + }), slog.Default()) + require.NoError(t, err) + assert.Empty(t, got[valueFQN]) +} + // TestEvaluateDynamicValueMappings_CrossDefinitionNoLeak verifies a mapping // only applies to its own definition: the same value segment under a different definition // is not entitled. diff --git a/service/pkg/server/services_test.go b/service/pkg/server/services_test.go index f78aced894..a16d31e19c 100644 --- a/service/pkg/server/services_test.go +++ b/service/pkg/server/services_test.go @@ -29,7 +29,7 @@ type mockTestServiceOptions struct { } const ( - numExpectedPolicyServices = 10 + numExpectedPolicyServices = 11 numExpectedEntityResolutionServiceVersions = 2 numExpectedAuthorizationServiceVersions = 2 ) From e0297d5ee47c24925c5634e91c9b6305d6aceb8a Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Thu, 18 Jun 2026 18:27:43 -0400 Subject: [PATCH 3/5] fix(policy): DSPX-2754 preserve FK violation for non-existent attribute value ensureNoDynamicValueMappingCoexistence ran on the CreateSubjectMapping path and returned ErrNotFound for a non-existent attribute value, masking the foreign-key violation that the create insert previously surfaced. Treat a not-found value as "nothing to guard" and return nil so the normal create path reports ErrForeignKeyViolation. The coexistence guard still fires for existing values. Signed-off-by: Krish Suchak --- service/policy/db/dynamic_value_mappings.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/service/policy/db/dynamic_value_mappings.go b/service/policy/db/dynamic_value_mappings.go index 67249cad99..96d03c33a8 100644 --- a/service/policy/db/dynamic_value_mappings.go +++ b/service/policy/db/dynamic_value_mappings.go @@ -333,7 +333,14 @@ func (c PolicyDBClient) ensureNoDynamicValueMappingCoexistence(ctx context.Conte } definitionID, err := c.queries.getAttributeDefinitionIDByValueID(ctx, attributeValueID) if err != nil { - return db.WrapIfKnownInvalidQueryErr(err) + wrapped := db.WrapIfKnownInvalidQueryErr(err) + // A non-existent attribute value has no parent definition to guard, so there is no + // coexisting dynamic mapping to reject. Let the normal create path surface the + // foreign-key violation instead of masking it as not-found. + if errors.Is(wrapped, db.ErrNotFound) { + return nil + } + return wrapped } count, err := c.queries.countDynamicValueMappingsByDefinitionID(ctx, definitionID) if err != nil { From 86312d75366bf27d97e06a96408b84bd954b1dc0 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Mon, 22 Jun 2026 16:03:53 -0400 Subject: [PATCH 4/5] test(policy): DSPX-2754 cover dynamic value mapping list pagination Add TestListByDefinition_Pagination asserting page boundaries (Total and NextOffset) across multiple dynamic value mappings on one definition, matching the subject-mapping list pagination coverage. Signed-off-by: Krish Suchak --- .../dynamic_value_mappings_test.go | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/service/integration/dynamic_value_mappings_test.go b/service/integration/dynamic_value_mappings_test.go index cf06d13f5f..28ab1df81f 100644 --- a/service/integration/dynamic_value_mappings_test.go +++ b/service/integration/dynamic_value_mappings_test.go @@ -195,6 +195,38 @@ func (s *DynamicValueMappingsSuite) TestListByDefinition() { s.Equal(attr.GetId(), resp.GetDynamicValueMappings()[0].GetAttributeDefinition().GetId()) } +func (s *DynamicValueMappingsSuite) TestListByDefinition_Pagination() { + attr := s.createDefinition("dvem_list_page", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + for _, selector := range []string{".a[]", ".b[]", ".c[]"} { + _, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(selector, policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().NoError(err) + } + + // first page: limit 2 of 3 -> next offset points past the page + first, err := s.db.PolicyClient.ListDynamicValueMappings(s.ctx, &dynamicvaluemapping.ListDynamicValueMappingsRequest{ + AttributeDefinitionId: attr.GetId(), + Pagination: &policy.PageRequest{Limit: 2}, + }) + s.Require().NoError(err) + s.Len(first.GetDynamicValueMappings(), 2) + s.Equal(int32(3), first.GetPagination().GetTotal()) + s.Equal(int32(2), first.GetPagination().GetNextOffset()) + + // second page: remaining item, no further pages + second, err := s.db.PolicyClient.ListDynamicValueMappings(s.ctx, &dynamicvaluemapping.ListDynamicValueMappingsRequest{ + AttributeDefinitionId: attr.GetId(), + Pagination: &policy.PageRequest{Limit: 2, Offset: 2}, + }) + s.Require().NoError(err) + s.Len(second.GetDynamicValueMappings(), 1) + s.Equal(int32(3), second.GetPagination().GetTotal()) + s.Equal(int32(0), second.GetPagination().GetNextOffset()) +} + // createDefinition makes a fresh attribute under the example.com namespace with no values // or subject mappings, so each test controls its own coexistence state. func (s *DynamicValueMappingsSuite) createDefinition(name string, rule policy.AttributeRuleTypeEnum) *policy.Attribute { From fa7dd0337716179f6968011c13b59649ef722eb1 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Mon, 22 Jun 2026 16:38:29 -0400 Subject: [PATCH 5/5] test(policy): DSPX-2754 assert dynamic value mapping pages do not overlap Strengthen TestListByDefinition_Pagination to verify the paginated pages partition the corpus: page items are distinct, page 2 does not repeat page 1, and the union covers all created mappings exactly once. Signed-off-by: Krish Suchak --- service/integration/dynamic_value_mappings_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/service/integration/dynamic_value_mappings_test.go b/service/integration/dynamic_value_mappings_test.go index 28ab1df81f..2920a9fbb1 100644 --- a/service/integration/dynamic_value_mappings_test.go +++ b/service/integration/dynamic_value_mappings_test.go @@ -216,6 +216,13 @@ func (s *DynamicValueMappingsSuite) TestListByDefinition_Pagination() { s.Equal(int32(3), first.GetPagination().GetTotal()) s.Equal(int32(2), first.GetPagination().GetNextOffset()) + // track ids to assert the pages partition the corpus (no overlap, no gaps) + seen := map[string]struct{}{} + for _, m := range first.GetDynamicValueMappings() { + seen[m.GetId()] = struct{}{} + } + s.Len(seen, 2, "first page should contain two distinct mappings") + // second page: remaining item, no further pages second, err := s.db.PolicyClient.ListDynamicValueMappings(s.ctx, &dynamicvaluemapping.ListDynamicValueMappingsRequest{ AttributeDefinitionId: attr.GetId(), @@ -225,6 +232,13 @@ func (s *DynamicValueMappingsSuite) TestListByDefinition_Pagination() { s.Len(second.GetDynamicValueMappings(), 1) s.Equal(int32(3), second.GetPagination().GetTotal()) s.Equal(int32(0), second.GetPagination().GetNextOffset()) + + for _, m := range second.GetDynamicValueMappings() { + _, overlap := seen[m.GetId()] + s.False(overlap, "page 2 must not repeat an item from page 1") + seen[m.GetId()] = struct{}{} + } + s.Len(seen, 3, "combined pages should cover all created mappings exactly once") } // createDefinition makes a fresh attribute under the example.com namespace with no values