diff --git a/service/authorization/v2/authorization.go b/service/authorization/v2/authorization.go index da5a870ec0..3cf6fe0992 100644 --- a/service/authorization/v2/authorization.go +++ b/service/authorization/v2/authorization.go @@ -98,7 +98,7 @@ func NewRegistration() *serviceregistry.Service[authzV2Connect.AuthorizationServ } retriever := access.NewEntitlementPolicyRetriever(as.sdk) - as.cache, err = NewEntitlementPolicyCache(context.Background(), l, retriever, cacheClient, refreshInterval) + as.cache, err = NewEntitlementPolicyCache(context.Background(), l, retriever, cacheClient, refreshInterval, authZCfg.AllowDynamicValueMappings) if err != nil { l.Error("failed to create entitlement policy cache", slog.Any("error", err)) panic(fmt.Errorf("failed to create entitlement policy cache: %w", err)) @@ -147,7 +147,7 @@ func (as *Service) GetEntitlements(ctx context.Context, req *connect.Request[aut withComprehensiveHierarchy := req.Msg.GetWithComprehensiveHierarchy() // When authorization service can consume cached policy, switch to the other PDP (process based on policy passed in) - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.AllowDynamicValueMappings, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToGetEntitlements, ErrFailedToInitPDP, err)) } @@ -174,7 +174,7 @@ func (as *Service) GetDecision(ctx context.Context, req *connect.Request[authzV2 return nil, err } - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.AllowDynamicValueMappings, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } @@ -224,7 +224,7 @@ func (as *Service) GetDecisionMultiResource(ctx context.Context, req *connect.Re return nil, err } - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.AllowDynamicValueMappings, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } @@ -277,7 +277,7 @@ func (as *Service) GetDecisionBulk(ctx context.Context, req *connect.Request[aut return nil, err } - pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.EnforceNamespacedEntitlements) + pdp, err := access.NewJustInTimePDP(ctx, as.logger, as.sdk, as.cache, as.config.AllowDirectEntitlements, as.config.AllowDynamicValueMappings, as.config.EnforceNamespacedEntitlements) if err != nil { return nil, statusifyError(ctx, as.logger, errors.Join(ErrFailedToInitPDP, err)) } diff --git a/service/authorization/v2/cache.go b/service/authorization/v2/cache.go index 57db585676..64a1140e15 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 ( @@ -48,6 +49,10 @@ type EntitlementPolicyCache struct { // SDK-connected retriever to fetch fresh data from policy services retriever *access.EntitlementPolicyRetriever + // allowDynamicValueMappings gates fetching the experimental dynamic value mappings, so cache + // health does not depend on that endpoint when the feature is disabled. + allowDynamicValueMappings bool + // Refresh state configuredRefreshInterval time.Duration stopRefresh chan struct{} @@ -60,10 +65,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 @@ -74,6 +80,7 @@ func NewEntitlementPolicyCache( retriever *access.EntitlementPolicyRetriever, cacheClient *cache.Cache, cacheRefreshInterval time.Duration, + allowDynamicValueMappings bool, ) (*EntitlementPolicyCache, error) { if cacheRefreshInterval == 0 { return nil, ErrCacheDisabled @@ -86,6 +93,7 @@ func NewEntitlementPolicyCache( logger: l, cacheClient: cacheClient, retriever: retriever, + allowDynamicValueMappings: allowDynamicValueMappings, configuredRefreshInterval: cacheRefreshInterval, stopRefresh: make(chan struct{}), refreshCompleted: make(chan struct{}), @@ -178,6 +186,15 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { if err != nil { return err } + // Only fetch the experimental dynamic value mappings when enabled, so cache readiness does not + // depend on that endpoint while the feature is off. + var dynamicValueMappings []*policy.DynamicValueMapping + if c.allowDynamicValueMappings { + dynamicValueMappings, err = c.retriever.ListAllDynamicValueMappings(ctx) + if err != nil { + return err + } + } registeredResources, err := c.retriever.ListAllRegisteredResources(ctx) if err != nil { return err @@ -200,6 +217,16 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error { return errors.Join(ErrFailedToSet, err) } + // Only cache dynamic value mappings when the feature is enabled, so a disabled feature does not + // store an empty slice (the fetch above is gated the same way). + if c.allowDynamicValueMappings { + 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 +297,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/authorization/v2/cache_test.go b/service/authorization/v2/cache_test.go index 8b56994c36..e2ae06fa05 100644 --- a/service/authorization/v2/cache_test.go +++ b/service/authorization/v2/cache_test.go @@ -21,7 +21,7 @@ func Test_NewEntitlementPolicyCache(t *testing.T) { refreshInterval := 10 * time.Second mockCache, _ := cache.TestCacheClient(mockCacheExpiry) - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval) + c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) require.NoError(t, err) assert.NotNil(t, c) assert.Equal(t, refreshInterval, c.configuredRefreshInterval) @@ -33,11 +33,11 @@ func Test_EntitlementPolicyCache_RefreshInterval(t *testing.T) { ctx := t.Context() mockCache, _ := cache.TestCacheClient(mockCacheExpiry) - _, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval) + _, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) require.ErrorIs(t, err, ErrCacheDisabled) refreshInterval = 10 * time.Second - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval) + c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) require.NoError(t, err) assert.NotNil(t, c) } @@ -53,7 +53,7 @@ func Test_EntitlementPolicyCache_Enabled(t *testing.T) { assert.False(t, c.IsEnabled()) assert.False(t, c.IsReady(ctx)) - c, err = NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval) + c, err = NewEntitlementPolicyCache(ctx, l, nil, mockCache, refreshInterval, false) require.NoError(t, err) assert.NotNil(t, c) assert.True(t, c.IsEnabled()) @@ -65,7 +65,7 @@ func Test_EntitlementPolicyCache_CacheMiss(t *testing.T) { ctx := t.Context() mockCache, _ := cache.TestCacheClient(mockCacheExpiry) - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour) + c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour, false) require.NoError(t, err) // No errors, but empty lists on cache misses @@ -93,7 +93,7 @@ func Test_EntitlementPolicyCache_CacheHits(t *testing.T) { _ = mockCache.Set(ctx, subjectMappingsCacheKey, subjMappingsList, nil) _ = mockCache.Set(ctx, registeredResourcesCacheKey, resourcesList, nil) - c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour) + c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour, false) require.NoError(t, err) // Allow for some concurrency overhead in cache library to prevent flakiness in tests @@ -114,3 +114,29 @@ func Test_EntitlementPolicyCache_CacheHits(t *testing.T) { assert.Len(t, registeredResources, 1) assert.Equal(t, "res1", registeredResources[0].GetName()) } + +func Test_EntitlementPolicyCache_DynamicValueMappings(t *testing.T) { + ctx := t.Context() + mockCache, _ := cache.TestCacheClient(mockCacheExpiry) + + c, err := NewEntitlementPolicyCache(ctx, l, nil, mockCache, 1*time.Hour, true) + require.NoError(t, err) + assert.True(t, c.allowDynamicValueMappings) + + // Cache miss: empty result, no error + mappings, err := c.ListAllDynamicValueMappings(ctx) + require.NoError(t, err) + assert.Empty(t, mappings) + + // Cache hit: returns what was set + dvmList := []*policy.DynamicValueMapping{{Id: "dvm-1"}} + _ = mockCache.Set(ctx, dynamicValueMappingsCacheKey, dvmList, nil) + + // Allow for some concurrency overhead in cache library to prevent flakiness in tests + time.Sleep(10 * time.Millisecond) + + mappings, err = c.ListAllDynamicValueMappings(ctx) + require.NoError(t, err) + assert.Len(t, mappings, 1) + assert.Equal(t, "dvm-1", mappings[0].GetId()) +} diff --git a/service/authorization/v2/config.go b/service/authorization/v2/config.go index f966681c21..579d34a5ca 100644 --- a/service/authorization/v2/config.go +++ b/service/authorization/v2/config.go @@ -56,6 +56,9 @@ type Config struct { // enable entity direct entitlements that do not require subject mappings AllowDirectEntitlements bool `mapstructure:"allow_direct_entitlements" json:"allow_direct_entitlements" default:"false"` + // enable definition-level dynamic value mappings in access decisioning + AllowDynamicValueMappings bool `mapstructure:"allow_dynamic_value_mappings" json:"allow_dynamic_value_mappings" default:"false"` + // enforce strict namespaced entitlement evaluation behavior in access decisioning EnforceNamespacedEntitlements bool `mapstructure:"enforce_namespaced_entitlements" json:"enforce_namespaced_entitlements" default:"false"` } @@ -107,6 +110,7 @@ func (c *Config) LogValue() slog.Value { ), ), slog.Bool("allow_direct_entitlements", c.AllowDirectEntitlements), + slog.Bool("allow_dynamic_value_mappings", c.AllowDynamicValueMappings), slog.Bool("enforce_namespaced_entitlements", c.EnforceNamespacedEntitlements), ) } diff --git a/service/integration/dynamic_value_mappings_test.go b/service/integration/dynamic_value_mappings_test.go new file mode 100644 index 0000000000..616cde9382 --- /dev/null +++ b/service/integration/dynamic_value_mappings_test.go @@ -0,0 +1,371 @@ +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/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" + "github.com/opentdf/platform/protocol/go/policy/unsafe" + "github.com/opentdf/platform/service/internal/fixtures" + "github.com/opentdf/platform/service/pkg/db" + 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, got.GetValueResolver().GetOperator()) + 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().ErrorIs(err, db.ErrEnumValueInvalid, "HIERARCHY definitions must be rejected") +} + +func (s *DynamicValueMappingsSuite) TestRejectsNotInOperator() { + attr := s.createDefinition("dvem_not_in", 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().ErrorIs(err, db.ErrEnumValueInvalid, "NOT_IN operator must be rejected for dynamic value resolution") +} + +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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().ErrorIs(err, db.ErrRestrictViolation, "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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + 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().ErrorIs(err, db.ErrRestrictViolation, "value-level subject mapping must not coexist with a dynamic mapping") +} + +func (s *DynamicValueMappingsSuite) TestNoCoexistence_RegisteredResourceAAV() { + attr := s.createDefinition("dvem_rr_coexist", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + val, err := s.db.PolicyClient.CreateAttributeValue(s.ctx, attr.GetId(), &attributes.CreateAttributeValueRequest{Value: "rr_dvm_val"}) + s.Require().NoError(err) + + _, err = s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".x[]", policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().NoError(err) + + regRes, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + NamespaceId: s.f.GetNamespaceKey("example.com").ID, + Name: "dvem_rr_coexist_res", + }) + s.Require().NoError(err) + + // A value under a definition with a dynamic mapping must not be added to a registered resource's + // action attribute values. + _, err = s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: regRes.GetId(), + Value: "rr_val_1", + ActionAttributeValues: []*registeredresources.ActionAttributeValue{ + { + ActionIdentifier: ®isteredresources.ActionAttributeValue_ActionName{ + ActionName: policydb.ActionRead.String(), + }, + AttributeValueIdentifier: ®isteredresources.ActionAttributeValue_AttributeValueFqn{ + AttributeValueFqn: val.GetFqn(), + }, + }, + }, + }) + s.Require().ErrorIs(err, db.ErrRestrictViolation, "value under a DVM definition must not be added to a registered resource's action attribute values") +} + +func (s *DynamicValueMappingsSuite) TestNoCoexistence_RegisteredResourceAAVThenDynamic() { + attr := s.createDefinition("dvem_rr_coexist_rev", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + val, err := s.db.PolicyClient.CreateAttributeValue(s.ctx, attr.GetId(), &attributes.CreateAttributeValueRequest{Value: "rr_dvm_val_rev"}) + s.Require().NoError(err) + + regRes, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + NamespaceId: s.f.GetNamespaceKey("example.com").ID, + Name: "dvem_rr_coexist_rev_res", + }) + s.Require().NoError(err) + + // The value is added to a registered resource's action attribute values before any dynamic + // mapping exists, so this succeeds. + _, err = s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: regRes.GetId(), + Value: "rr_val_rev_1", + ActionAttributeValues: []*registeredresources.ActionAttributeValue{ + { + ActionIdentifier: ®isteredresources.ActionAttributeValue_ActionName{ + ActionName: policydb.ActionRead.String(), + }, + AttributeValueIdentifier: ®isteredresources.ActionAttributeValue_AttributeValueFqn{ + AttributeValueFqn: val.GetFqn(), + }, + }, + }, + }) + s.Require().NoError(err) + + // The definition now has a value referenced by a registered resource's action attribute values; + // a dynamic mapping must be rejected. + _, err = s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{ + AttributeDefinitionId: attr.GetId(), + ValueResolver: s.resolver(".x[]", policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + Actions: []*policy.Action{s.readAction()}, + }) + s.Require().ErrorIs(err, db.ErrRestrictViolation, "dynamic mapping must not coexist with values already on a registered resource's action attribute values") +} + +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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + 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().ErrorIs(err, db.ErrRestrictViolation, "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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS), + }) + s.Require().NoError(err) + s.Equal(".accounts[]", updated.GetValueResolver().GetSubjectExternalSelectorValue()) + s.Equal(policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS, updated.GetValueResolver().GetOperator()) + + _, err = s.db.PolicyClient.DeleteDynamicValueMapping(s.ctx, created.GetId()) + s.Require().NoError(err) + + _, err = s.db.PolicyClient.GetDynamicValueMapping(s.ctx, created.GetId()) + s.Require().ErrorIs(err, db.ErrNotFound, "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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + 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()) +} + +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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN), + 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()) + + // 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(), + 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()) + + 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 +// 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, operator policy.SubjectMappingOperatorEnum) *policy.DynamicValueResolver { + return &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: selector, + Operator: operator, + } +} + +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/evaluate.go b/service/internal/access/v2/evaluate.go index 3700916b65..1735060307 100644 --- a/service/internal/access/v2/evaluate.go +++ b/service/internal/access/v2/evaluate.go @@ -17,12 +17,13 @@ import ( ) var ( - ErrInvalidResource = errors.New("access: invalid resource") - ErrFQNNotFound = errors.New("access: FQN not found") - ErrDefinitionNotFound = errors.New("access: definition not found for FQN") - ErrFailedEvaluation = errors.New("access: failed to evaluate definition") - ErrMissingRequiredSpecifiedRule = errors.New("access: AttributeDefinition rule cannot be unspecified") - ErrUnrecognizedRule = errors.New("access: unrecognized AttributeDefinition rule") + ErrInvalidResource = errors.New("access: invalid resource") + ErrFQNNotFound = errors.New("access: FQN not found") + ErrDefinitionNotFound = errors.New("access: definition not found for FQN") + ErrFailedEvaluation = errors.New("access: failed to evaluate definition") + ErrMissingRequiredSpecifiedRule = errors.New("access: AttributeDefinition rule cannot be unspecified") + ErrUnrecognizedRule = errors.New("access: unrecognized AttributeDefinition rule") + ErrDynamicValueMappingEvaluation = errors.New("access: failed to evaluate dynamic value mappings") ) // getResourceDecision evaluates the access decision for a single resource, driving the flows diff --git a/service/internal/access/v2/helpers.go b/service/internal/access/v2/helpers.go index decb42f2c3..4af02e632c 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, 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..ded01e3475 100644 --- a/service/internal/access/v2/just_in_time_pdp.go +++ b/service/internal/access/v2/just_in_time_pdp.go @@ -50,6 +50,7 @@ func NewJustInTimePDP( sdk *otdfSDK.SDK, store EntitlementPolicyStore, allowDirectEntitlements bool, + allowDynamicValueMappings bool, namespacedPolicy bool, ) (*JustInTimePDP, error) { var err error @@ -91,8 +92,16 @@ func NewJustInTimePDP( if err != nil { return nil, fmt.Errorf("failed to fetch all obligations: %w", err) } + // Experimental: only load dynamic value mappings when the feature is enabled. + var allDynamicValueMappings []*policy.DynamicValueMapping + if allowDynamicValueMappings { + 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 := NewPolicyDecisionPoint(ctx, log, allAttributes, allSubjectMappings, allRegisteredResources, allowDirectEntitlements, namespacedPolicy, WithDynamicValueMappings(allDynamicValueMappings, allowDynamicValueMappings)) 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..530951be6f 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -61,7 +61,9 @@ type PolicyDecisionPoint struct { allEntitleableAttributesByValueFQN map[string]*attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue allRegisteredResourceValuesByFQN map[string]*policy.RegisteredResourceValue allAttributesByDefinitionFQN map[string]*policy.Attribute + dynamicMappingsByDefinitionFQN subjectmappingbuiltin.DynamicValueMappingsByDefinitionFQN allowDirectEntitlements bool + allowDynamicValueMappings bool namespacedPolicy bool } @@ -74,9 +76,28 @@ var ( ErrMissingRequiredPolicy = errors.New("access: both attribute definitions and subject mappings must be provided or neither") ) +// pdpOptions holds optional, experimental PolicyDecisionPoint features. +type pdpOptions struct { + dynamicValueMappings []*policy.DynamicValueMapping + allowDynamicValueMappings bool +} + +// PDPOption configures optional PolicyDecisionPoint behavior. +type PDPOption func(*pdpOptions) + +// WithDynamicValueMappings enables the experimental, definition-level dynamic value mapping feature. +// When allow is false (or this option is omitted) the mappings are not evaluated at decision time. +func WithDynamicValueMappings(mappings []*policy.DynamicValueMapping, allow bool) PDPOption { + return func(o *pdpOptions) { + o.dynamicValueMappings = mappings + o.allowDynamicValueMappings = allow + } +} + // NewPolicyDecisionPoint creates a new Policy Decision Point instance. // It is presumed that all Attribute Definitions and Subject Mappings are valid and contain the entirety of entitlement policy. -// Attribute Values without Subject Mappings will be ignored in decisioning. +// Attribute Values without Subject Mappings will be ignored in decisioning. The experimental dynamic +// value mapping feature is enabled via WithDynamicValueMappings. func NewPolicyDecisionPoint( ctx context.Context, l *logger.Logger, @@ -85,7 +106,15 @@ func NewPolicyDecisionPoint( allRegisteredResources []*policy.RegisteredResource, allowDirectEntitlements bool, namespacedPolicy bool, + opts ...PDPOption, ) (*PolicyDecisionPoint, error) { + var options pdpOptions + for _, opt := range opts { + opt(&options) + } + allDynamicValueMappings := options.dynamicValueMappings + allowDynamicValueMappings := options.allowDynamicValueMappings + var err error if l == nil { @@ -160,6 +189,35 @@ 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 + } + + definitionFQN := mapping.GetAttributeDefinition().GetFqn() + + // Defense in depth alongside validateDynamicValueMapping: the mapping's own definition may + // carry an unset rule, so reject HIERARCHY using the canonical definition. A missing entry + // yields a nil definition whose rule reads UNSPECIFIED. This indicates inconsistent policy + // data that needs correction, so log at error level and still decide. + if allAttributesByDefinitionFQN[definitionFQN].GetRule() == policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY { + l.ErrorContext(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) + } + allRegisteredResourceValuesByFQN := make(map[string]*policy.RegisteredResourceValue) for _, rr := range allRegisteredResources { if err := validateRegisteredResource(rr); err != nil { @@ -192,12 +250,14 @@ func NewPolicyDecisionPoint( } pdp := &PolicyDecisionPoint{ - l, - allEntitleableAttributesByValueFQN, - allRegisteredResourceValuesByFQN, - allAttributesByDefinitionFQN, - allowDirectEntitlements, - namespacedPolicy, + logger: l, + allEntitleableAttributesByValueFQN: allEntitleableAttributesByValueFQN, + allRegisteredResourceValuesByFQN: allRegisteredResourceValuesByFQN, + allAttributesByDefinitionFQN: allAttributesByDefinitionFQN, + dynamicMappingsByDefinitionFQN: dynamicMappingsByDefinitionFQN, + allowDirectEntitlements: allowDirectEntitlements, + allowDynamicValueMappings: allowDynamicValueMappings, + namespacedPolicy: namespacedPolicy, } return pdp, nil } @@ -245,6 +305,7 @@ func (p *PolicyDecisionPoint) GetDecision( p.allRegisteredResourceValuesByFQN, p.allEntitleableAttributesByValueFQN, p.allAttributesByDefinitionFQN, /* action, */ + p.dynamicMappingsByDefinitionFQN, resources, p.allowDirectEntitlements, ) @@ -301,6 +362,24 @@ func (p *PolicyDecisionPoint) GetDecision( } } + // Evaluate dynamic, definition-level value entitlement mappings and merge + // their results into the entitled FQNs before rule evaluation. + if p.allowDynamicValueMappings && len(p.dynamicMappingsByDefinitionFQN) > 0 { + dynamicEntitledFQNsToActions, err := subjectmappingbuiltin.EvaluateDynamicValueMappingsWithActions( + p.dynamicMappingsByDefinitionFQN, + decisionableAttributes, + entityRepresentation, + l.Logger, + ) + if err != nil { + return nil, nil, fmt.Errorf("%w: %w", ErrDynamicValueMappingEvaluation, 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 +434,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..374068e507 --- /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, 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[]", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + }, + Actions: []*policy.Action{testActionRead}, + Namespace: namespace, + } + pdp, err := NewPolicyDecisionPoint( + s.T().Context(), + s.logger, + []*policy.Attribute{attr}, + []*policy.SubjectMapping{}, + nil, + false, // allowDirectEntitlements: dynamic mappings synthesize values on their own + false, // namespacedPolicy + WithDynamicValueMappings([]*policy.DynamicValueMapping{mapping}, true), + ) + 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..f7001df899 100644 --- a/service/internal/access/v2/validators.go +++ b/service/internal/access/v2/validators.go @@ -127,6 +127,48 @@ 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.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.GetSubjectExternalSelectorValue() == "" { + return fmt.Errorf("mapping's value resolver selector is empty: %w", ErrInvalidDynamicValueMapping) + } + switch resolver.GetOperator() { + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS: + // supported existential operators + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN: + return fmt.Errorf("NOT_IN is unsupported for dynamic value resolution: %w", ErrInvalidDynamicValueMapping) + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_UNSPECIFIED: + return fmt.Errorf("mapping's value resolver operator is unspecified: %w", ErrInvalidDynamicValueMapping) + default: + return fmt.Errorf("mapping's value resolver operator is unsupported: %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..ac87c97c76 --- /dev/null +++ b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go @@ -0,0 +1,170 @@ +package subjectmappingbuiltin + +import ( + "errors" + "fmt" + "log/slog" + "strings" + + "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" +) + +// Named errors for invalid dynamic value resolver operators, so the PDP/authz layer can errors.Is +// them and log that the stored policy is malformed and needs correction. +var ( + ErrDynamicResolverOperatorUnspecified = errors.New("unspecified dynamic value resolver operator") + ErrDynamicResolverOperatorNotIn = errors.New("NOT_IN is unsupported for dynamic value resolution") + ErrDynamicResolverOperatorUnsupported = errors.New("unsupported dynamic value resolver operator") +) + +// 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 + } + + // Flatten each entity in the representation once; a mapping matches if any entity satisfies it. + flattenedEntities := make([]flattening.Flattened, 0, len(entityRepresentation.GetAdditionalProps())) + 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) + } + flattenedEntities = append(flattenedEntities, flattenedEntity) + } + + 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 { + // A mapping is satisfied if any entity in the representation matches it. Evaluate + // existentially and append the mapping's actions at most once per value FQN, so multiple + // matching entities (e.g. person + client) do not duplicate the entitlement. + matched := false + for _, flattenedEntity := range flattenedEntities { + ok, err := evaluateDynamicValueMapping(mapping, flattenedEntity, segment) + if err != nil { + return nil, err + } + if ok { + matched = true + break + } + } + 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: the SubjectConditionSet's subject sets are AND-ed (every subject set + // must pass), matching subject-mapping semantics (see EvaluateEntitlements in + // subject_mapping_builtin.go). Multiple mappings on the same definition are OR-ed by the caller. + 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 operator. The match is existential over +// the entity values. NOT_IN is unsupported because dynamic resolution is existential. +func evaluateValueResolver(resolver *policy.DynamicValueResolver, entity flattening.Flattened, segment string) (bool, error) { + operator := resolver.GetOperator() + var match func(entityValue string) bool + switch operator { + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN: + match = func(entityValue string) bool { return entityValue == segment } + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS: + // Substring match: this over-matches by design (e.g. "admin" matches "superadmin", + // "admin-readonly"). Prefer IN (exact) unless substring matching is genuinely intended. + match = func(entityValue string) bool { return strings.Contains(entityValue, segment) } + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN: + return false, ErrDynamicResolverOperatorNotIn + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_UNSPECIFIED: + return false, ErrDynamicResolverOperatorUnspecified + default: + return false, fmt.Errorf("%w: %s", ErrDynamicResolverOperatorUnsupported, operator) + } + + entityValues := flattening.GetFromFlattened(entity, resolver.GetSubjectExternalSelectorValue()) + for _, ev := range entityValues { + if ev == nil { + continue + } + if match(fmt.Sprintf("%v", ev)) { + 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..7360bb0190 --- /dev/null +++ b/service/internal/subjectmappingbuiltin/dynamic_value_mapping_builtin_test.go @@ -0,0 +1,213 @@ +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" +) + +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, operator policy.SubjectMappingOperatorEnum, scs *policy.SubjectConditionSet, actionNames ...string) *policy.DynamicValueMapping { + return &policy.DynamicValueMapping{ + AttributeDefinition: &policy.Attribute{Fqn: defFQN}, + ValueResolver: &policy.DynamicValueResolver{ + SubjectExternalSelectorValue: selector, + Operator: operator, + }, + 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, 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_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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS, 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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, 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_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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, 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. +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.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, 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/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/pkg/server/services_test.go b/service/pkg/server/services_test.go index 67b81b0327..17c3164123 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 ) 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..a6f0d4c28b --- /dev/null +++ b/service/policy/adr/0005-dynamic-attribute-value-entitlements-spike.md @@ -0,0 +1,159 @@ +# 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. An upstream design ADR +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 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 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: comparing against the resource value + +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 supplies a different right-hand operand: 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"]`). + +No new operator enum is required for this. `DynamicValueResolver` reuses `SubjectMappingOperatorEnum`: +`IN` matches when the resource segment equals any resolved entity value, and `IN_CONTAINS` matches on +substring containment. The match is inherently existential over the resolved values, so `NOT_IN` is +rejected (a definition-wide "not entitled" has no meaning at decision time). Reusing the existing +operator keeps the resolver and subject-mapping vocabularies aligned; see +[`evaluateValueResolver`](../../internal/subjectmappingbuiltin/dynamic_value_mapping_builtin.go). + +**Combination Semantics.** Multiple dynamic value mappings on one definition are OR-ed (any match +entitles); within a mapping, the optional SubjectConditionSet's subject sets are AND-ed (every set +must pass). This matches subject-mapping evaluation. + +> [!CAUTION] +> `IN_CONTAINS` is substring-based and over-matches by design: a segment `admin` matches +> `superadmin`, `admin-readonly`, `org-admin`, etc. Prefer exact `IN` unless substring matching is +> genuinely intended, since a broad substring can entitle values that were not meant to be granted. + +> [!NOTE] +> The original spike prototyped a dedicated `RESOURCE_VALUE_IN` operator to make the direction +> explicit. Implementation reused `SubjectMappingOperatorEnum` instead: the resolver already fixes the +> comparison direction (resource segment against the resolved set), so a separate enum added surface +> without changing 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, dynamic meaning implicit | ✓ `SubjectMappingOperatorEnum` in a resolver whose direction is explicit (`NOT_IN` rejected) | ✓ | +| 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 | ✓ optional `SubjectConditionSet` pre-gate | ✗ | +| 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, so + `MRN-123` from the IdP would not match `mrn-123` in the FQN. The spike explored a pluggable + `Canonicalizer` (lowercase + trim). The shipped resolver does **not** canonicalize: matching is exact + and case-sensitive (`IN` compares equality, `IN_CONTAINS` substring), keeping behavior predictable and + the operator vocabulary shared with static subject mappings. Where case/whitespace normalization should + live, and whether it is configurable per definition, is left as a follow-up rather than baked into the + first release. +- **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`). The constraint is on value-level *subject + mappings*, not on the existence of attribute *values*: a definition may still have concrete values (for + obligation triggers, FQN resolution, etc.) alongside a dynamic mapping. What is disallowed is pairing + those values with their own subject mappings on a definition that is also entitled dynamically. + For the same reason, a value under a dynamically-entitled definition cannot be added to a Registered + Resource's action attribute values: those values resolve at decision time and are not meaningful as + static registered-resource entitlements. +- **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 +the upstream design ADR. 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..48374c26b4 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. + 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 dynamic value 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..a503a9b9d3 --- /dev/null +++ b/service/policy/db/dynamic_value_mappings.go @@ -0,0 +1,449 @@ +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" +) + +type dynamicValueMappingRow struct { + id string + attributeDefinitionID string + subjectExternalSelectorValue string + operator int16 + 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 err := validateDynamicValueResolverOperator(resolver.GetOperator()); err != nil { + return nil, err + } + + 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. + if err := c.ensureNoValueSubjectMappingCoexistence(ctx, attr.GetId()); err != nil { + return nil, err + } + + // Enforce no-coexistence with registered-resource action attribute values: a definition + // whose values are already statically entitled on a registered resource cannot also have a + // dynamic value mapping. Mirrors the forward guard in createRegisteredResourceActionAttributeValues. + if err := c.ensureNoRegisteredResourceAAVCoexistence(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(), + Operator: int16(resolver.GetOperator()), + 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) + } + + return c.hydrateDynamicValueMapping(ctx, dynamicValueMappingRow{ + id: row.ID, + attributeDefinitionID: row.AttributeDefinitionID, + subjectExternalSelectorValue: row.SubjectExternalSelectorValue, + operator: row.Operator, + 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, + operator: row.Operator, + 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 err := validateDynamicValueResolverOperator(resolver.GetOperator()); err != nil { + return nil, err + } + updateParams.SubjectExternalSelectorValue = pgtypeText(resolver.GetSubjectExternalSelectorValue()) + updateParams.Operator = pgtype.Int2{Int16: int16(resolver.GetOperator()), 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, + Operator: policy.SubjectMappingOperatorEnum(row.operator), + }, + 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")), + ) + } +} + +// validateDynamicValueResolverOperator rejects operators that are invalid for dynamic value +// resolution. Resolution is existential over the entity values, so NOT_IN has no meaning. +func validateDynamicValueResolverOperator(operator policy.SubjectMappingOperatorEnum) error { + switch operator { + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS: + return nil + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN: + return errors.Join(db.ErrEnumValueInvalid, errors.New("value_resolver.operator NOT_IN is unsupported for dynamic value resolution")) + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_UNSPECIFIED: + return errors.Join(db.ErrEnumValueInvalid, errors.New("value_resolver.operator must be specified")) + default: + return errors.Join(db.ErrEnumValueInvalid, errors.New("value_resolver.operator is unsupported")) + } +} + +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 +} + +// ensureNoRegisteredResourceAAVCoexistence rejects creation of a dynamic mapping when any of the +// definition's values are already referenced by a registered resource's action attribute values. +// Mirrors the forward guard in createRegisteredResourceActionAttributeValues. +func (c PolicyDBClient) ensureNoRegisteredResourceAAVCoexistence(ctx context.Context, definitionID string) error { + count, err := c.queries.countRegisteredResourceActionAttributeValuesByDefinitionID(ctx, definitionID) + if err != nil { + return db.WrapIfKnownInvalidQueryErr(err) + } + if count > 0 { + return errors.Join(db.ErrRestrictViolation, + fmt.Errorf("attribute definition [%s] has values referenced by a registered resource's action attribute values; it cannot also have a dynamic value mapping", definitionID)) + } + return nil +} + +// definitionHasDynamicValueMapping reports whether the attribute value's parent definition already +// has a dynamic value mapping, returning the parent definition ID for error context. A non-existent +// attribute value has no parent definition to guard, so it returns (false) and lets the caller's +// normal path surface the foreign-key violation instead of masking it as not-found. +func (c PolicyDBClient) definitionHasDynamicValueMapping(ctx context.Context, attributeValueID string) (string, bool, error) { + if attributeValueID == "" { + return "", false, nil + } + definitionID, err := c.queries.getAttributeDefinitionIDByValueID(ctx, attributeValueID) + if err != nil { + wrapped := db.WrapIfKnownInvalidQueryErr(err) + if errors.Is(wrapped, db.ErrNotFound) { + return "", false, nil + } + return "", false, wrapped + } + count, err := c.queries.countDynamicValueMappingsByDefinitionID(ctx, definitionID) + if err != nil { + return "", false, db.WrapIfKnownInvalidQueryErr(err) + } + return definitionID, count > 0, 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 { + definitionID, has, err := c.definitionHasDynamicValueMapping(ctx, attributeValueID) + if err != nil { + return err + } + if has { + 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..34e44ab4e8 --- /dev/null +++ b/service/policy/db/dynamic_value_mappings.sql.go @@ -0,0 +1,627 @@ +// 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 countRegisteredResourceActionAttributeValuesByDefinitionID = `-- name: countRegisteredResourceActionAttributeValuesByDefinitionID :one +SELECT COUNT(rav.id) +FROM registered_resource_action_attribute_values rav +JOIN attribute_values av ON rav.attribute_value_id = av.id +WHERE av.attribute_definition_id = $1 +` + +// Counts registered-resource action-attribute-values whose attribute value belongs to the given +// definition. Used to enforce no-coexistence with dynamic value entitlement mappings. +// +// SELECT COUNT(rav.id) +// FROM registered_resource_action_attribute_values rav +// JOIN attribute_values av ON rav.attribute_value_id = av.id +// WHERE av.attribute_definition_id = $1 +func (q *Queries) countRegisteredResourceActionAttributeValuesByDefinitionID(ctx context.Context, attributeDefinitionID string) (int64, error) { + row := q.db.QueryRow(ctx, countRegisteredResourceActionAttributeValuesByDefinitionID, 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, + operator, + metadata, + subject_condition_set_id, + namespace_id + ) + VALUES ( + $1, + $2, + $3, + $4, + $5::uuid, + $6::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($7::uuid[]) +) +SELECT id FROM inserted_mapping +` + +type createDynamicValueMappingParams struct { + AttributeDefinitionID string `json:"attribute_definition_id"` + SubjectExternalSelectorValue string `json:"subject_external_selector_value"` + Operator int16 `json:"operator"` + 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, +// operator, +// metadata, +// subject_condition_set_id, +// namespace_id +// ) +// VALUES ( +// $1, +// $2, +// $3, +// $4, +// $5::uuid, +// $6::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($7::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.Operator, + 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.operator, + 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"` + Operator int16 `json:"operator"` + 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.operator, +// 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.Operator, + &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.operator, + 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"` + Operator int16 `json:"operator"` + 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.operator, +// 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.Operator, + &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), + operator = COALESCE($3::SMALLINT, operator), + subject_condition_set_id = COALESCE($4::UUID, subject_condition_set_id) + WHERE id = $5 + RETURNING id + ), + action_delete AS ( + DELETE FROM dynamic_value_mapping_actions + WHERE + dynamic_value_mapping_id = $5 + AND $6::UUID[] IS NOT NULL + AND action_id NOT IN (SELECT unnest($6::UUID[])) + ), + action_insert AS ( + INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) + SELECT + $5, + a + FROM unnest($6::UUID[]) AS a + WHERE + $6::UUID[] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM dynamic_value_mapping_actions + WHERE dynamic_value_mapping_id = $5 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"` + Operator pgtype.Int2 `json:"operator"` + 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), +// operator = COALESCE($3::SMALLINT, operator), +// subject_condition_set_id = COALESCE($4::UUID, subject_condition_set_id) +// WHERE id = $5 +// RETURNING id +// ), +// action_delete AS ( +// DELETE FROM dynamic_value_mapping_actions +// WHERE +// dynamic_value_mapping_id = $5 +// AND $6::UUID[] IS NOT NULL +// AND action_id NOT IN (SELECT unnest($6::UUID[])) +// ), +// action_insert AS ( +// INSERT INTO dynamic_value_mapping_actions (dynamic_value_mapping_id, action_id) +// SELECT +// $5, +// a +// FROM unnest($6::UUID[]) AS a +// WHERE +// $6::UUID[] IS NOT NULL +// AND NOT EXISTS ( +// SELECT 1 +// FROM dynamic_value_mapping_actions +// WHERE dynamic_value_mapping_id = $5 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.Operator, + 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.md b/service/policy/db/migrations/20260618000000_add_dynamic_value_mappings.md new file mode 100644 index 0000000000..0f26544219 --- /dev/null +++ b/service/policy/db/migrations/20260618000000_add_dynamic_value_mappings.md @@ -0,0 +1,73 @@ +# Add Dynamic Value Mappings + +This migration adds the `dynamic_value_mappings` and `dynamic_value_mapping_actions` tables that back +the `DynamicValueMapping` policy primitive. + +See ADR 0005, +[Dynamic Attribute Value Entitlement](../../adr/0005-dynamic-attribute-value-entitlements-spike.md), +for the design rationale. + +## Why + +Entitling highly dynamic, high-cardinality attribute values (medical record numbers, account IDs, +email-like identifiers) previously required duplicating each value as an `AttributeValue` paired with +its own `SubjectMapping` and `SubjectConditionSet`, kept in sync with an external system of record. A +dynamic value mapping raises 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 at +decision time. + +## Changes + +1. `dynamic_value_mappings` — definition-scoped dynamic entitlement mappings: + - `attribute_definition_id` — foreign key to `attribute_definitions(id)` with `ON DELETE CASCADE`. + - `subject_external_selector_value` — selector resolved against the entity representation, compared + to the requested resource value segment. + - `operator` — `policy.SubjectMappingOperatorEnum` value (`IN` or `IN_CONTAINS`). + - `subject_condition_set_id` — optional static pre-gate, foreign key to `subject_condition_set(id)` + with `ON DELETE CASCADE`, evaluated with normal `SubjectConditionSet` semantics. + - `namespace_id` — foreign key to `attribute_namespaces(id)` with `ON DELETE CASCADE`. + - `metadata`, `created_at`, `updated_at`, plus an `updated_at` trigger. + - Indexes on `attribute_definition_id`, `subject_condition_set_id`, and `namespace_id`. + +2. `dynamic_value_mapping_actions` — join table linking a dynamic value mapping to its actions: + - `dynamic_value_mapping_id` — foreign key to `dynamic_value_mappings(id)` with `ON DELETE CASCADE`. + - `action_id` — foreign key to `actions(id)` with `ON DELETE CASCADE`. + - Composite `PRIMARY KEY (dynamic_value_mapping_id, action_id)`, which also covers lookups, so no + separate index is added. + +```mermaid +erDiagram + attribute_definitions ||--o{ dynamic_value_mappings : has + subject_condition_set ||--o{ dynamic_value_mappings : gates + attribute_namespaces ||--o{ dynamic_value_mappings : scopes + dynamic_value_mappings ||--o{ dynamic_value_mapping_actions : has + actions ||--o{ dynamic_value_mapping_actions : has + + dynamic_value_mappings { + UUID id PK + UUID attribute_definition_id FK + TEXT subject_external_selector_value + SMALLINT operator + UUID subject_condition_set_id FK + UUID namespace_id FK + JSONB metadata + TIMESTAMP created_at + TIMESTAMP updated_at + } + + dynamic_value_mapping_actions { + UUID dynamic_value_mapping_id PK,FK + UUID action_id PK,FK + } +``` + +## Resulting Behavior + +- A definition can carry a dynamic value mapping that entitles dynamically-requested values without + pre-provisioning each value plus a subject mapping. +- Dynamic value mappings do not coexist with value-level subject mappings on the same definition, nor + with values referenced by a registered resource's action attribute values; both directions are + enforced. +- Deleting a definition, subject condition set, namespace, or action cascades to remove the associated + dynamic value mappings or action links. 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..d024942761 --- /dev/null +++ b/service/policy/db/migrations/20260618000000_add_dynamic_value_mappings.sql @@ -0,0 +1,61 @@ +-- +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 + operator + subject_external_selector_value TEXT NOT NULL, + operator SMALLINT NOT NULL, + -- 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'; +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.operator IS 'policy.SubjectMappingOperatorEnum value'; + +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..cce7f3fb85 100644 --- a/service/policy/db/models.go +++ b/service/policy/db/models.go @@ -234,6 +234,26 @@ type BaseKey struct { KeyAccessServerKeyID pgtype.UUID `json:"key_access_server_key_id"` } +// Definition-scoped dynamic value entitlement mappings +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.SubjectMappingOperatorEnum value + Operator int16 `json:"operator"` + 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..34f31c81ca --- /dev/null +++ b/service/policy/db/queries/dynamic_value_mappings.sql @@ -0,0 +1,223 @@ +---------------------------------------------------------------- +-- 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.operator, + 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.operator, + 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, + operator, + metadata, + subject_condition_set_id, + namespace_id + ) + VALUES ( + @attribute_definition_id, + @subject_external_selector_value, + @operator, + @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), + operator = COALESCE(sqlc.narg('operator')::SMALLINT, operator), + 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: countRegisteredResourceActionAttributeValuesByDefinitionID :one +-- Counts registered-resource action-attribute-values whose attribute value belongs to the given +-- definition. Used to enforce no-coexistence with dynamic value entitlement mappings. +SELECT COUNT(rav.id) +FROM registered_resource_action_attribute_values rav +JOIN attribute_values av ON rav.attribute_value_id = av.id +WHERE av.attribute_definition_id = $1; + +-- name: getAttributeDefinitionIDByValueID :one +SELECT attribute_definition_id +FROM attribute_values +WHERE id = $1; diff --git a/service/policy/db/registered_resources.go b/service/policy/db/registered_resources.go index 47efd5393e..2266d8bb20 100644 --- a/service/policy/db/registered_resources.go +++ b/service/policy/db/registered_resources.go @@ -630,6 +630,18 @@ func (c PolicyDBClient) createRegisteredResourceActionAttributeValues(ctx contex return err } + // A definition entitled dynamically resolves its values at decision time; those values are + // not meaningful as static registered-resource entitlements. Reject them, mirroring the + // value-level subject-mapping coexistence rule. + definitionID, hasDVM, err := c.definitionHasDynamicValueMapping(ctx, attributeValueID) + if err != nil { + return err + } + if hasDVM { + return errors.Join(db.ErrRestrictViolation, + fmt.Errorf("attribute value [%s] is under attribute definition [%s] which has a dynamic value mapping; it cannot be added to a registered resource's action attribute values", attributeValueID, definitionID)) + } + createActionAttributeValueParams[i] = createRegisteredResourceActionAttributeValuesParams{ RegisteredResourceValueID: registeredResourceValueID, ActionID: actionID, diff --git a/service/policy/db/schema_erd.md b/service/policy/db/schema_erd.md index 9e43e4fe29..b25db7bf8a 100644 --- a/service/policy/db/schema_erd.md +++ b/service/policy/db/schema_erd.md @@ -100,6 +100,23 @@ erDiagram uuid key_access_server_key_id FK } + dynamic_value_mapping_actions { + uuid action_id PK,FK + uuid dynamic_value_mapping_id PK,FK + } + + dynamic_value_mappings { + uuid attribute_definition_id FK + timestamp_with_time_zone created_at + uuid id PK + jsonb metadata + uuid namespace_id FK + smallint operator "policy.SubjectMappingOperatorEnum value" + uuid subject_condition_set_id FK + text subject_external_selector_value "Selector resolved against the entity representation, compared to the requested resource value segment" + timestamp_with_time_zone updated_at + } + goose_db_version { integer id PK boolean is_applied @@ -225,6 +242,7 @@ erDiagram uuid group_id FK "Foreign key to the parent group of the resource mapping (optional, a resource mapping may not be in a group)" uuid id PK "Primary key for the table" jsonb metadata "Metadata for the resource mapping (see protos for structure)" + uuid namespace_id FK "Optional owning namespace of the resource mapping. If the mapping belongs to a group, it matches the group namespace. The mapped attribute value may belong to a different namespace." ARRAY terms "Terms to match against resource data (i.e. translations #quot;roi#quot;, #quot;rey#quot;, or #quot;kung#quot; in a terms list could map to the value #quot;/attr/card/value/king#quot;)" timestamp_with_time_zone updated_at } @@ -269,6 +287,7 @@ erDiagram } actions }o--|| attribute_namespaces : "namespace_id" + dynamic_value_mapping_actions }o--|| actions : "action_id" obligation_triggers }o--|| actions : "action_id" registered_resource_action_attribute_values }o--|| actions : "action_id" subject_mapping_actions }o--|| actions : "action_id" @@ -280,15 +299,18 @@ erDiagram attribute_definitions }o--|| attribute_namespaces : "namespace_id" attribute_fqns }o--|| attribute_definitions : "attribute_id" attribute_values }o--|| attribute_definitions : "attribute_definition_id" + dynamic_value_mappings }o--|| attribute_definitions : "attribute_definition_id" attribute_fqns }o--|| attribute_namespaces : "namespace_id" attribute_fqns }o--|| attribute_values : "value_id" attribute_namespace_key_access_grants }o--|| attribute_namespaces : "namespace_id" attribute_namespace_key_access_grants }o--|| key_access_servers : "key_access_server_id" attribute_namespace_public_key_map }o--|| attribute_namespaces : "namespace_id" attribute_namespace_public_key_map }o--|| key_access_server_keys : "key_access_server_key_id" + dynamic_value_mappings }o--|| attribute_namespaces : "namespace_id" obligation_definitions }o--|| attribute_namespaces : "namespace_id" registered_resources }o--|| attribute_namespaces : "namespace_id" resource_mapping_groups }o--|| attribute_namespaces : "namespace_id" + resource_mappings }o--|| attribute_namespaces : "namespace_id" subject_condition_set }o--|| attribute_namespaces : "namespace_id" subject_mappings }o--|| attribute_namespaces : "namespace_id" attribute_value_key_access_grants }o--|| attribute_values : "attribute_value_id" @@ -300,6 +322,8 @@ erDiagram resource_mappings }o--|| attribute_values : "attribute_value_id" subject_mappings }o--|| attribute_values : "attribute_value_id" base_keys }o--|| key_access_server_keys : "key_access_server_key_id" + dynamic_value_mapping_actions }o--|| dynamic_value_mappings : "dynamic_value_mapping_id" + dynamic_value_mappings }o--|| subject_condition_set : "subject_condition_set_id" key_access_server_keys }o--|| key_access_servers : "key_access_server_id" key_access_server_keys }o--|| provider_config : "provider_config_id" obligation_values_standard }o--|| obligation_definitions : "obligation_definition_id" diff --git a/service/policy/db/subject_mappings.go b/service/policy/db/subject_mappings.go index 04fd2fb45c..ea0b1209b9 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. + 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..905206f026 --- /dev/null +++ b/service/policy/dynamicvaluemapping/dynamic_value_mapping.go @@ -0,0 +1,198 @@ +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.Any("error", err)) + 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, + } + + // Get-then-update and the success audit run in a transaction so the audited "original" is + // consistent with the applied update. + err := s.dbClient.RunInTx(ctx, func(txClient *policydb.PolicyDBClient) error { + original, err := txClient.GetDynamicValueMapping(ctx, id) + if err != nil { + s.logger.Audit.PolicyCRUDFailure(ctx, auditParams) + return err + } + + updated, err := txClient.UpdateDynamicValueMapping(ctx, req.Msg) + if err != nil { + s.logger.Audit.PolicyCRUDFailure(ctx, auditParams) + return err + } + + auditParams.Original = original + auditParams.Updated = updated + s.logger.Audit.PolicyCRUDSuccess(ctx, auditParams) + + rsp.DynamicValueMapping = updated + return nil + }) + if err != nil { + return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextUpdateFailed, slog.String("id", id), slog.String("dynamicValueMapping", req.Msg.String())) + } + + 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),