Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions service/integration/attributes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,47 @@ func (s *AttributesSuite) Test_GetEntitleableAttributesByFqns() {
assertValueEntry(fqn2, value2.ID, 1)
}

func (s *AttributesSuite) Test_GetEntitleableAttributesByFqns_ActiveValuesAndNormalization() {
created, err := s.db.PolicyClient.CreateAttribute(s.ctx, &attributes.CreateAttributeRequest{
Name: "test__entitleable_active_values", NamespaceId: fixtureNamespaceID,
Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY,
Values: []string{"high", "mid", "low"}, AllowTraversal: wrapperspb.Bool(true),
})
s.Require().NoError(err)
got, err := s.db.PolicyClient.GetAttribute(s.ctx, created.GetId())
s.Require().NoError(err)
_, err = s.db.PolicyClient.DeactivateAttributeValue(s.ctx, got.GetValues()[1].GetId())
s.Require().NoError(err)
high, mid, low := got.GetValues()[0].GetFqn(), got.GetValues()[1].GetFqn(), got.GetValues()[2].GetFqn()
resp, err := s.db.PolicyClient.GetEntitleableAttributesByFqns(s.ctx, &attributes.GetEntitleableAttributesByFqnsRequest{
Fqns: []string{strings.ToUpper(low), low},
})
s.Require().NoError(err)
s.Len(resp.GetFqnEntitleableAttributes(), 1)
values := resp.GetDefinitions()[got.GetFqn()].GetValues()
s.Require().Len(values, 2)
s.Equal(high, values[0].GetFqn())
s.Equal(low, values[1].GetFqn())
// Traversal must not turn an explicitly inactive value into an unknown value.
_, err = s.db.PolicyClient.GetEntitleableAttributesByFqns(s.ctx, &attributes.GetEntitleableAttributesByFqnsRequest{Fqns: []string{low, mid}})
s.Require().ErrorIs(err, db.ErrAttributeValueInactive)
_, err = s.db.PolicyClient.DeactivateAttribute(s.ctx, created.GetId())
s.Require().NoError(err)
_, err = s.db.PolicyClient.GetEntitleableAttributesByFqns(s.ctx, &attributes.GetEntitleableAttributesByFqnsRequest{Fqns: []string{low}})
s.Require().ErrorIs(err, db.ErrNotFound)
}

func (s *AttributesSuite) Test_GetEntitleableAttributesByFqns_EmptyAndMixedMissing() {
resp, err := s.db.PolicyClient.GetEntitleableAttributesByFqns(s.ctx, &attributes.GetEntitleableAttributesByFqnsRequest{})
s.Require().NoError(err)
s.Empty(resp.GetDefinitions())
s.Empty(resp.GetFqnEntitleableAttributes())
_, err = s.db.PolicyClient.GetEntitleableAttributesByFqns(s.ctx, &attributes.GetEntitleableAttributesByFqnsRequest{
Fqns: []string{"https://example.com/attr/attr1/value/value1", "https://entitleable-dne.example/attr/nope/value/nope"},
})
s.Require().ErrorIs(err, db.ErrNotFound)
}

func (s *AttributesSuite) Test_GetEntitleableAttributesByFqns_NonExistentFqn_Fails() {
// Matches GetAttributeValuesByFqns: a requested FQN that does not exist errors
// rather than being silently absent.
Expand Down
2 changes: 1 addition & 1 deletion service/policy/db/attribute_fqn.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ func (c *PolicyDBClient) GetEntitleableAttributesByFqns(ctx context.Context, r *
FqnEntitleableAttributes: map[string]*attributes.GetEntitleableAttributesByFqnsResponse_EntitleableAttribute{},
}, nil
}
normalized, pairs, err := c.resolveValueFqns(ctx, fqns)
normalized, pairs, err := c.resolveEntitleableValueFqns(ctx, fqns)
if err != nil {
return nil, err
}
Expand Down
79 changes: 79 additions & 0 deletions service/policy/db/entitleable_attributes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package db

import (
"context"
"fmt"
"strings"

"github.com/opentdf/platform/protocol/go/policy"
"github.com/opentdf/platform/protocol/go/policy/attributes"
"github.com/opentdf/platform/service/pkg/db"
)

// resolveEntitleableValueFqns preserves value lookup semantics without hydrating
// encryption policy or resource mappings that authorization never consumes.
func (c *PolicyDBClient) resolveEntitleableValueFqns(ctx context.Context, fqns []string) ([]string, map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue, error) {
normalized := make([]string, len(fqns))
definitionFqns := make([]string, 0, len(fqns))
seenDefinitions := make(map[string]struct{}, len(fqns))
requested := make(map[string]struct{}, len(fqns))
for i, fqn := range fqns {
fqn = strings.ToLower(fqn)
normalized[i] = fqn
requested[fqn] = struct{}{}
defFqn := definitionFqnFromValueFqn(fqn)
if _, seen := seenDefinitions[defFqn]; defFqn != "" && !seen {
seenDefinitions[defFqn] = struct{}{}
definitionFqns = append(definitionFqns, defFqn)
}
}
rows, err := c.queries.getEntitleableAttributeValues(ctx, getEntitleableAttributeValuesParams{
DefinitionFqns: definitionFqns,
ValueFqns: normalized,
})
if err != nil {
return nil, nil, db.WrapIfKnownInvalidQueryErr(err)
}
definitions := make(map[string]*policy.Attribute, len(definitionFqns))
traversable := make(map[string]bool, len(definitionFqns))
pairs := make(map[string]*attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue, len(fqns))
for _, row := range rows {
attr, exists := definitions[row.DefinitionFqn]
if !exists {
attr = &policy.Attribute{
Id: row.DefinitionID, Fqn: row.DefinitionFqn,
Rule: attributesRuleTypeEnumTransformOut(string(row.Rule)),
Namespace: &policy.Namespace{Id: row.NamespaceID, Name: row.NamespaceName, Fqn: row.NamespaceFqn},
}
definitions[row.DefinitionFqn] = attr
traversable[row.DefinitionFqn] = row.AllowTraversal
}
if row.ValueID == "" {
continue
}
_, isRequested := requested[row.ValueFqn]
if !row.ValueActive {
if isRequested {
return nil, nil, fmt.Errorf("value fqn [%s] inactive: %w", row.ValueFqn, db.ErrAttributeValueInactive)
}
continue
}
value := &policy.Value{Id: row.ValueID, Fqn: row.ValueFqn}
attr.Values = append(attr.Values, value)
if isRequested {
pairs[row.ValueFqn] = &attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{Attribute: attr, Value: value}
}
}
for _, fqn := range normalized {
if _, found := pairs[fqn]; found {
continue
}
defFqn := definitionFqnFromValueFqn(fqn)
if traversable[defFqn] {
pairs[fqn] = &attributes.GetAttributeValuesByFqnsResponse_AttributeAndValue{Attribute: definitions[defFqn]}
continue
}
return nil, nil, fmt.Errorf("could not find value for FQN [%s]: %w", fqn, db.ErrNotFound)
}
return normalized, pairs, nil
}
129 changes: 129 additions & 0 deletions service/policy/db/entitleable_attributes.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions service/policy/db/queries/entitleable_attributes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- name: getEntitleableAttributeValues :many
-- Authorization needs value identity and rule context, not grants, keys, or resource
-- mappings. Only hierarchy definitions need their other active values, in policy order.
WITH definitions AS (
SELECT ad.id, ad.namespace_id, ad.rule, ad.allow_traversal, ad.values_order,
df.fqn AS definition_fqn
FROM attribute_definitions ad
JOIN attribute_fqns df ON df.attribute_id = ad.id AND df.value_id IS NULL
JOIN attribute_namespaces ns ON ns.id = ad.namespace_id AND ns.active = TRUE
WHERE df.fqn = ANY(@definition_fqns::text[]) AND ad.active = TRUE
), requested_values AS (
SELECT av.id, av.attribute_definition_id, av.active, vf.fqn
FROM attribute_fqns vf
JOIN attribute_values av ON av.id = vf.value_id
JOIN definitions d ON d.id = av.attribute_definition_id
WHERE vf.fqn = ANY(@value_fqns::text[])
), selected_values AS (
SELECT * FROM requested_values

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need some comments about each subquery's intent

UNION ALL
SELECT av.id, av.attribute_definition_id, av.active, vf.fqn
FROM definitions d
JOIN attribute_values av ON av.attribute_definition_id = d.id AND av.active = TRUE
JOIN attribute_fqns vf ON vf.value_id = av.id
WHERE d.rule = 'HIERARCHY' AND NOT EXISTS (SELECT 1 FROM requested_values rv WHERE rv.id = av.id)
)
SELECT d.id AS definition_id, d.definition_fqn, d.rule, d.allow_traversal,

@jakedoublev jakedoublev Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the state of allow_traversal be affecting anything here so that the API behavior is true:

// A value that does not exist under a definition with allow_traversal is returned
// with its definition and an empty value identity (no value_id, no subject
// mappings), so front-loaded values still carry their definition context.```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we need an integration test for the behavior with allow_traversal true or false

ns.id AS namespace_id, ns.name AS namespace_name, nf.fqn AS namespace_fqn,
COALESCE(v.id::text, '')::text AS value_id,
COALESCE(v.fqn, '')::text AS value_fqn,
COALESCE(v.active, FALSE)::boolean AS value_active
FROM definitions d
JOIN attribute_namespaces ns ON ns.id = d.namespace_id
JOIN attribute_fqns nf ON nf.namespace_id = ns.id AND nf.attribute_id IS NULL AND nf.value_id IS NULL
LEFT JOIN selected_values v ON v.attribute_definition_id = d.id
ORDER BY d.id, CASE WHEN d.rule = 'HIERARCHY' THEN ARRAY_POSITION(d.values_order, v.id) END, v.id;
Loading