From be8b337f89b8182130353fbb93b692545da669a0 Mon Sep 17 00:00:00 2001 From: strantalis Date: Sat, 5 Sep 2026 07:17:00 -0400 Subject: [PATCH] fix(authz): keep shared policy values immutable Signed-off-by: strantalis --- service/internal/access/v2/pdp.go | 4 +- .../internal/access/v2/pdp_immutable_test.go | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 service/internal/access/v2/pdp_immutable_test.go diff --git a/service/internal/access/v2/pdp.go b/service/internal/access/v2/pdp.go index 5a1eeda521..139ec84a20 100644 --- a/service/internal/access/v2/pdp.go +++ b/service/internal/access/v2/pdp.go @@ -16,6 +16,7 @@ import ( attrs "github.com/opentdf/platform/protocol/go/policy/attributes" "github.com/opentdf/platform/service/internal/subjectmappingbuiltin" "github.com/opentdf/platform/service/logger" + "google.golang.org/protobuf/proto" ) // Decision represents the overall access decision for an entity. @@ -140,7 +141,7 @@ func NewPolicyDecisionPoint( // Not every value may have a subject mapping and be entitleable, but a lookup must still be possible for _, value := range attr.GetValues() { mapped := &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ - Value: value, + Value: proto.CloneOf(value), Attribute: attr, } allEntitleableAttributesByValueFQN[value.GetFqn()] = mapped @@ -181,6 +182,7 @@ func NewPolicyDecisionPoint( if err != nil { return nil, fmt.Errorf("failed to get attribute definition: %w", err) } + mappedValue = proto.CloneOf(mappedValue) mappedValue.SubjectMappings = []*policy.SubjectMapping{sm} mapped := &attrs.GetAttributeValuesByFqnsResponse_AttributeAndValue{ Value: mappedValue, diff --git a/service/internal/access/v2/pdp_immutable_test.go b/service/internal/access/v2/pdp_immutable_test.go new file mode 100644 index 0000000000..24b00a51da --- /dev/null +++ b/service/internal/access/v2/pdp_immutable_test.go @@ -0,0 +1,52 @@ +package access + +import ( + "log/slog" + "sync" + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/service/logger" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestPolicyConstructionDoesNotMutateSharedValues(t *testing.T) { + const definitionFQN = "https://scale.example/attr/department" + value := &policy.Value{Fqn: definitionFQN + "/value/engineering"} + attr := &policy.Attribute{Fqn: definitionFQN, Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, Values: []*policy.Value{value}} + // The second mapping exercises values absent from the definition's value list. + mappings := []*policy.SubjectMapping{ + {Id: "existing", AttributeValue: value, Actions: []*policy.Action{{Name: "read"}}}, + {Id: "additional", AttributeValue: &policy.Value{Fqn: definitionFQN + "/value/sales"}, Actions: []*policy.Action{{Name: "read"}}}, + } + originalAttribute := proto.CloneOf(attr) + originalMapping := proto.CloneOf(mappings[1]) + log := &logger.Logger{Logger: slog.New(slog.DiscardHandler)} + ctx := t.Context() + const constructions = 16 + results := make(chan *PolicyDecisionPoint, constructions) + errors := make(chan error, constructions) + var workers sync.WaitGroup + for range constructions { + workers.Go(func() { + pdp, err := NewPolicyDecisionPoint(ctx, log, []*policy.Attribute{attr}, mappings, nil, true, false) + results <- pdp + errors <- err + }) + } + workers.Wait() + close(results) + close(errors) + for err := range errors { + require.NoError(t, err) + } + for pdp := range results { + for _, mapping := range mappings { + got := pdp.allEntitleableAttributesByValueFQN[mapping.GetAttributeValue().GetFqn()] + require.Len(t, got.GetValue().GetSubjectMappings(), 1) + } + } + require.True(t, proto.Equal(originalAttribute, attr)) + require.True(t, proto.Equal(originalMapping, mappings[1])) +}