Skip to content

Commit f2a5f6b

Browse files
committed
feat(policy): DSPX-2754 dynamic attribute value entitlement mappings
Service consumer for dynamic value mappings, built on protocol/go v0.34.0 and sdk v0.23.0: - DB: dynamic_value_mappings table + queries + CRUD, no-coexistence guard with value-level subject mappings, comparison + case_insensitive columns. - Service: DynamicValueMappingService, validators, and PDP wiring that loads dynamic mappings via the SDK and evaluates them alongside subject mappings. - Evaluation: shared comparison helper (EQUALS/CONTAINS/STARTS_WITH/ENDS_WITH) used by both static conditions (with quantifier + deprecated-operator normalization) and the dynamic resolver (existential, case_insensitive). protocol/go protos, the dynamicvaluemapping package, and the sdk wrapper landed in #3580 and #3635; this PR bumps to those releases. Signed-off-by: Krish Suchak <suchak.krish@gmail.com>
1 parent 0ec99ee commit f2a5f6b

26 files changed

Lines changed: 2679 additions & 83 deletions

service/authorization/v2/cache.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import (
1414
)
1515

1616
const (
17-
attributesCacheKey = "attributes_cache_key"
18-
subjectMappingsCacheKey = "subject_mappings_cache_key"
19-
registeredResourcesCacheKey = "registered_resources_cache_key"
20-
obligationsCacheKey = "obligations_cache_key"
17+
attributesCacheKey = "attributes_cache_key"
18+
subjectMappingsCacheKey = "subject_mappings_cache_key"
19+
dynamicValueMappingsCacheKey = "dynamic_value_mappings_cache_key"
20+
registeredResourcesCacheKey = "registered_resources_cache_key"
21+
obligationsCacheKey = "obligations_cache_key"
2122
)
2223

2324
var (
@@ -60,10 +61,11 @@ type EntitlementPolicyCache struct {
6061
// The EntitlementPolicy struct holds all the cached entitlement policy, as generics allow one
6162
// data type per service cache instance.
6263
type EntitlementPolicy struct {
63-
Attributes []*policy.Attribute
64-
SubjectMappings []*policy.SubjectMapping
65-
RegisteredResources []*policy.RegisteredResource
66-
Obligations []*policy.Obligation
64+
Attributes []*policy.Attribute
65+
SubjectMappings []*policy.SubjectMapping
66+
DynamicValueMappings []*policy.DynamicValueMapping
67+
RegisteredResources []*policy.RegisteredResource
68+
Obligations []*policy.Obligation
6769
}
6870

6971
// NewEntitlementPolicyCache holds a platform-provided cache client and manages a periodic refresh of
@@ -178,6 +180,10 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error {
178180
if err != nil {
179181
return err
180182
}
183+
dynamicValueMappings, err := c.retriever.ListAllDynamicValueMappings(ctx)
184+
if err != nil {
185+
return err
186+
}
181187
registeredResources, err := c.retriever.ListAllRegisteredResources(ctx)
182188
if err != nil {
183189
return err
@@ -200,6 +206,12 @@ func (c *EntitlementPolicyCache) Refresh(ctx context.Context) error {
200206
return errors.Join(ErrFailedToSet, err)
201207
}
202208

209+
err = c.cacheClient.Set(ctx, dynamicValueMappingsCacheKey, dynamicValueMappings, authzCacheTags)
210+
if err != nil {
211+
c.isCacheFilled = false
212+
return errors.Join(ErrFailedToSet, err)
213+
}
214+
203215
err = c.cacheClient.Set(ctx, registeredResourcesCacheKey, registeredResources, authzCacheTags)
204216
if err != nil {
205217
c.isCacheFilled = false
@@ -270,6 +282,28 @@ func (c *EntitlementPolicyCache) ListAllSubjectMappings(ctx context.Context) ([]
270282
return subjectMappings, nil
271283
}
272284

285+
// ListAllDynamicValueMappings returns the cached dynamic value entitlement mappings, or none on a cache miss
286+
func (c *EntitlementPolicyCache) ListAllDynamicValueMappings(ctx context.Context) ([]*policy.DynamicValueMapping, error) {
287+
var (
288+
mappings []*policy.DynamicValueMapping
289+
ok bool
290+
)
291+
292+
cached, err := c.cacheClient.Get(ctx, dynamicValueMappingsCacheKey)
293+
if err != nil {
294+
if errors.Is(err, cache.ErrCacheMiss) {
295+
return mappings, nil
296+
}
297+
return nil, fmt.Errorf("%w, dynamic value mappings: %w", ErrFailedToGet, err)
298+
}
299+
300+
mappings, ok = cached.([]*policy.DynamicValueMapping)
301+
if !ok {
302+
return nil, fmt.Errorf("%w: %T", ErrCachedTypeNotExpected, cached)
303+
}
304+
return mappings, nil
305+
}
306+
273307
// ListAllRegisteredResources returns the cached registered resources, or none in the event of a cache miss
274308
func (c *EntitlementPolicyCache) ListAllRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) {
275309
var (

service/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ require (
3232
github.com/opentdf/platform/lib/flattening v0.1.3
3333
github.com/opentdf/platform/lib/identifier v0.4.0
3434
github.com/opentdf/platform/lib/ocrypto v0.12.0
35-
github.com/opentdf/platform/protocol/go v0.33.1
36-
github.com/opentdf/platform/sdk v0.22.0
35+
github.com/opentdf/platform/protocol/go v0.34.0
36+
github.com/opentdf/platform/sdk v0.23.0
3737
github.com/pressly/goose/v3 v3.24.3
3838
github.com/spf13/cobra v1.9.1
3939
github.com/spf13/viper v1.20.1

service/go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,10 @@ github.com/opentdf/platform/lib/identifier v0.4.0 h1:gJf4FqHxqpMdMdMwhI9QmvfHEfM
273273
github.com/opentdf/platform/lib/identifier v0.4.0/go.mod h1:+gONr5mVf1YlLorZUeRefxiudYfC6JeQN7EwrKMk4g8=
274274
github.com/opentdf/platform/lib/ocrypto v0.12.0 h1:N449KWy7VdMO0JwfsrG0kM6Uy8VrEnVvBciwzRHwnlg=
275275
github.com/opentdf/platform/lib/ocrypto v0.12.0/go.mod h1:51UTmAWO6C8ghuMXiktpn63N+fLUQxY6zo8D65Ly0wQ=
276-
github.com/opentdf/platform/protocol/go v0.33.1 h1:nLg5D++Oo1hlgPD6nR+AchnfpkZeOQFGzJMz2MmJM4U=
277-
github.com/opentdf/platform/protocol/go v0.33.1/go.mod h1:6A0vQJ5D4ZTLReWAp8Y/7jTFzlYCmL/IfMJWDHZS6M0=
278-
github.com/opentdf/platform/sdk v0.22.0 h1:IKXm/E9/ZkNnaOZLQEd14VsKy0YhmagwSMFhmt6tja0=
279-
github.com/opentdf/platform/sdk v0.22.0/go.mod h1:gemTuilaIaJ2MKojaiqF1aE1tDLKvt1GgRELM4m8qt4=
276+
github.com/opentdf/platform/protocol/go v0.34.0 h1:HJstzUyOnbE8c39UIAf3ASkjkkqLEMxy/D0oiUWuzSA=
277+
github.com/opentdf/platform/protocol/go v0.34.0/go.mod h1:6A0vQJ5D4ZTLReWAp8Y/7jTFzlYCmL/IfMJWDHZS6M0=
278+
github.com/opentdf/platform/sdk v0.23.0 h1:TwHXItQcXVcN9dtC07aU8XqE52xnGlawVruASpLBGIg=
279+
github.com/opentdf/platform/sdk v0.23.0/go.mod h1:gy1AWvGufZOYm0uWoYS6QWHvUpVoN4GSf8KQPTnn5cE=
280280
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
281281
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
282282
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package integration
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"testing"
7+
8+
"github.com/opentdf/platform/protocol/go/policy"
9+
"github.com/opentdf/platform/protocol/go/policy/attributes"
10+
"github.com/opentdf/platform/protocol/go/policy/dynamicvaluemapping"
11+
"github.com/opentdf/platform/protocol/go/policy/subjectmapping"
12+
"github.com/opentdf/platform/protocol/go/policy/unsafe"
13+
"github.com/opentdf/platform/service/internal/fixtures"
14+
policydb "github.com/opentdf/platform/service/policy/db"
15+
"github.com/stretchr/testify/suite"
16+
)
17+
18+
type DynamicValueMappingsSuite struct {
19+
suite.Suite
20+
f fixtures.Fixtures
21+
db fixtures.DBInterface
22+
//nolint:containedctx // Only used for test suite
23+
ctx context.Context
24+
}
25+
26+
func (s *DynamicValueMappingsSuite) SetupSuite() {
27+
slog.Info("setting up db.DynamicValueMappings test suite")
28+
s.ctx = context.Background()
29+
c := *Config
30+
c.DB.Schema = "test_opentdf_dynamic_value_mappings"
31+
s.db = fixtures.NewDBInterface(s.ctx, c)
32+
s.f = fixtures.NewFixture(s.db)
33+
s.f.Provision(s.ctx)
34+
}
35+
36+
func (s *DynamicValueMappingsSuite) TearDownSuite() {
37+
slog.Info("tearing down db.DynamicValueMappings test suite")
38+
s.f.TearDown(s.ctx)
39+
}
40+
41+
func TestDynamicValueMappingsSuite(t *testing.T) {
42+
if testing.Short() {
43+
t.Skip("skipping dynamic_value_mappings integration tests")
44+
}
45+
suite.Run(t, new(DynamicValueMappingsSuite))
46+
}
47+
48+
func (s *DynamicValueMappingsSuite) TestCreateAndGet() {
49+
attr := s.createDefinition("dvem_create_ok", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF)
50+
51+
created, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
52+
AttributeDefinitionId: attr.GetId(),
53+
ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
54+
Actions: []*policy.Action{s.readAction()},
55+
})
56+
s.Require().NoError(err)
57+
s.Require().NotEmpty(created.GetId())
58+
59+
got, err := s.db.PolicyClient.GetDynamicValueMapping(s.ctx, created.GetId())
60+
s.Require().NoError(err)
61+
s.Equal(attr.GetId(), got.GetAttributeDefinition().GetId())
62+
s.Equal(".patientAssignments[]", got.GetValueResolver().GetSubjectExternalSelectorValue())
63+
s.Equal(policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS, got.GetValueResolver().GetComparison())
64+
s.Len(got.GetActions(), 1)
65+
s.Nil(got.GetSubjectConditionSet(), "optional static pre-gate omitted")
66+
}
67+
68+
func (s *DynamicValueMappingsSuite) TestCreateWithStaticGate() {
69+
attr := s.createDefinition("dvem_create_gate", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF)
70+
71+
created, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
72+
AttributeDefinitionId: attr.GetId(),
73+
ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
74+
Actions: []*policy.Action{s.readAction()},
75+
NewSubjectConditionSet: s.sampleSCSCreate(),
76+
})
77+
s.Require().NoError(err)
78+
79+
got, err := s.db.PolicyClient.GetDynamicValueMapping(s.ctx, created.GetId())
80+
s.Require().NoError(err)
81+
s.Require().NotNil(got.GetSubjectConditionSet(), "static pre-gate should be hydrated")
82+
s.NotEmpty(got.GetSubjectConditionSet().GetSubjectSets())
83+
}
84+
85+
func (s *DynamicValueMappingsSuite) TestRejectsHierarchyDefinition() {
86+
attr := s.createDefinition("dvem_hierarchy", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY)
87+
88+
_, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
89+
AttributeDefinitionId: attr.GetId(),
90+
ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
91+
Actions: []*policy.Action{s.readAction()},
92+
})
93+
s.Require().Error(err, "HIERARCHY definitions must be rejected")
94+
}
95+
96+
func (s *DynamicValueMappingsSuite) TestNoCoexistence_SubjectMappingThenDynamic() {
97+
attr := s.createDefinition("dvem_coexist_fwd", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF)
98+
val, err := s.db.PolicyClient.CreateAttributeValue(s.ctx, attr.GetId(), &attributes.CreateAttributeValueRequest{Value: "v1"})
99+
s.Require().NoError(err)
100+
101+
_, err = s.db.PolicyClient.CreateSubjectMapping(s.ctx, &subjectmapping.CreateSubjectMappingRequest{
102+
AttributeValueId: val.GetId(),
103+
Actions: []*policy.Action{s.readAction()},
104+
NewSubjectConditionSet: s.sampleSCSCreate(),
105+
})
106+
s.Require().NoError(err)
107+
108+
// definition now has a value-level subject mapping; a dynamic mapping must be rejected
109+
_, err = s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
110+
AttributeDefinitionId: attr.GetId(),
111+
ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
112+
Actions: []*policy.Action{s.readAction()},
113+
})
114+
s.Require().Error(err, "dynamic mapping must not coexist with value-level subject mappings")
115+
}
116+
117+
func (s *DynamicValueMappingsSuite) TestNoCoexistence_DynamicThenSubjectMapping() {
118+
attr := s.createDefinition("dvem_coexist_rev", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF)
119+
120+
_, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
121+
AttributeDefinitionId: attr.GetId(),
122+
ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
123+
Actions: []*policy.Action{s.readAction()},
124+
})
125+
s.Require().NoError(err)
126+
127+
val, err := s.db.PolicyClient.CreateAttributeValue(s.ctx, attr.GetId(), &attributes.CreateAttributeValueRequest{Value: "v1"})
128+
s.Require().NoError(err)
129+
130+
// definition now has a dynamic mapping; a value-level subject mapping must be rejected
131+
_, err = s.db.PolicyClient.CreateSubjectMapping(s.ctx, &subjectmapping.CreateSubjectMappingRequest{
132+
AttributeValueId: val.GetId(),
133+
Actions: []*policy.Action{s.readAction()},
134+
NewSubjectConditionSet: s.sampleSCSCreate(),
135+
})
136+
s.Require().Error(err, "value-level subject mapping must not coexist with a dynamic mapping")
137+
}
138+
139+
func (s *DynamicValueMappingsSuite) TestRejectsRuleChangeToHierarchy() {
140+
attr := s.createDefinition("dvem_rule_guard", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF)
141+
142+
_, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
143+
AttributeDefinitionId: attr.GetId(),
144+
ValueResolver: s.resolver(".x[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
145+
Actions: []*policy.Action{s.readAction()},
146+
})
147+
s.Require().NoError(err)
148+
149+
_, err = s.db.PolicyClient.UnsafeUpdateAttribute(s.ctx, &unsafe.UnsafeUpdateAttributeRequest{
150+
Id: attr.GetId(),
151+
Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY,
152+
})
153+
s.Require().Error(err, "changing the rule to HIERARCHY must be rejected when a dynamic mapping exists")
154+
}
155+
156+
func (s *DynamicValueMappingsSuite) TestUpdateAndDelete() {
157+
attr := s.createDefinition("dvem_update_delete", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF)
158+
159+
created, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
160+
AttributeDefinitionId: attr.GetId(),
161+
ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
162+
Actions: []*policy.Action{s.readAction()},
163+
})
164+
s.Require().NoError(err)
165+
166+
updated, err := s.db.PolicyClient.UpdateDynamicValueMapping(s.ctx, &dynamicvaluemapping.UpdateDynamicValueMappingRequest{
167+
Id: created.GetId(),
168+
ValueResolver: s.resolver(".accounts[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_CONTAINS),
169+
})
170+
s.Require().NoError(err)
171+
s.Equal(".accounts[]", updated.GetValueResolver().GetSubjectExternalSelectorValue())
172+
s.Equal(policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_CONTAINS, updated.GetValueResolver().GetComparison())
173+
174+
_, err = s.db.PolicyClient.DeleteDynamicValueMapping(s.ctx, created.GetId())
175+
s.Require().NoError(err)
176+
177+
_, err = s.db.PolicyClient.GetDynamicValueMapping(s.ctx, created.GetId())
178+
s.Require().Error(err, "mapping should be gone after delete")
179+
}
180+
181+
func (s *DynamicValueMappingsSuite) TestListByDefinition() {
182+
attr := s.createDefinition("dvem_list", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF)
183+
_, err := s.db.PolicyClient.CreateDynamicValueMapping(s.ctx, &dynamicvaluemapping.CreateDynamicValueMappingRequest{
184+
AttributeDefinitionId: attr.GetId(),
185+
ValueResolver: s.resolver(".patientAssignments[]", policy.ConditionComparisonOperatorEnum_CONDITION_COMPARISON_OPERATOR_ENUM_EQUALS),
186+
Actions: []*policy.Action{s.readAction()},
187+
})
188+
s.Require().NoError(err)
189+
190+
resp, err := s.db.PolicyClient.ListDynamicValueMappings(s.ctx, &dynamicvaluemapping.ListDynamicValueMappingsRequest{
191+
AttributeDefinitionId: attr.GetId(),
192+
})
193+
s.Require().NoError(err)
194+
s.Require().Len(resp.GetDynamicValueMappings(), 1)
195+
s.Equal(attr.GetId(), resp.GetDynamicValueMappings()[0].GetAttributeDefinition().GetId())
196+
}
197+
198+
// createDefinition makes a fresh attribute under the example.com namespace with no values
199+
// or subject mappings, so each test controls its own coexistence state.
200+
func (s *DynamicValueMappingsSuite) createDefinition(name string, rule policy.AttributeRuleTypeEnum) *policy.Attribute {
201+
nsID := s.f.GetNamespaceKey("example.com").ID
202+
attr, err := s.db.PolicyClient.CreateAttribute(s.ctx, &attributes.CreateAttributeRequest{
203+
Name: name,
204+
NamespaceId: nsID,
205+
Rule: rule,
206+
})
207+
s.Require().NoError(err)
208+
s.Require().NotNil(attr)
209+
return attr
210+
}
211+
212+
func (s *DynamicValueMappingsSuite) readAction() *policy.Action {
213+
return s.f.GetStandardAction(policydb.ActionRead.String())
214+
}
215+
216+
func (s *DynamicValueMappingsSuite) resolver(selector string, comparison policy.ConditionComparisonOperatorEnum) *policy.DynamicValueResolver {
217+
return &policy.DynamicValueResolver{
218+
SubjectExternalSelectorValue: selector,
219+
Comparison: comparison,
220+
}
221+
}
222+
223+
func (s *DynamicValueMappingsSuite) sampleSCSCreate() *subjectmapping.SubjectConditionSetCreate {
224+
return &subjectmapping.SubjectConditionSetCreate{
225+
SubjectSets: []*policy.SubjectSet{{
226+
ConditionGroups: []*policy.ConditionGroup{{
227+
BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND,
228+
Conditions: []*policy.Condition{{
229+
SubjectExternalSelectorValue: ".department",
230+
Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN,
231+
SubjectExternalValues: []string{"cardiology"},
232+
}},
233+
}},
234+
}},
235+
}
236+
}

0 commit comments

Comments
 (0)