diff --git a/service/integration/attributes_test.go b/service/integration/attributes_test.go index 19b12d5bdf..3bce3bfff6 100644 --- a/service/integration/attributes_test.go +++ b/service/integration/attributes_test.go @@ -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. diff --git a/service/policy/db/attribute_fqn.go b/service/policy/db/attribute_fqn.go index 2c71c19b99..37232865aa 100644 --- a/service/policy/db/attribute_fqn.go +++ b/service/policy/db/attribute_fqn.go @@ -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 } diff --git a/service/policy/db/entitleable_attributes.go b/service/policy/db/entitleable_attributes.go new file mode 100644 index 0000000000..d014aeb977 --- /dev/null +++ b/service/policy/db/entitleable_attributes.go @@ -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 +} diff --git a/service/policy/db/entitleable_attributes.sql.go b/service/policy/db/entitleable_attributes.sql.go new file mode 100644 index 0000000000..12c543ec02 --- /dev/null +++ b/service/policy/db/entitleable_attributes.sql.go @@ -0,0 +1,129 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: entitleable_attributes.sql + +package db + +import ( + "context" +) + +const getEntitleableAttributeValues = `-- name: getEntitleableAttributeValues :many +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($1::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($2::text[]) +), selected_values AS ( + SELECT id, attribute_definition_id, active, fqn FROM requested_values + 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, + 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 +` + +type getEntitleableAttributeValuesParams struct { + DefinitionFqns []string `json:"definition_fqns"` + ValueFqns []string `json:"value_fqns"` +} + +type getEntitleableAttributeValuesRow struct { + DefinitionID string `json:"definition_id"` + DefinitionFqn string `json:"definition_fqn"` + Rule AttributeDefinitionRule `json:"rule"` + AllowTraversal bool `json:"allow_traversal"` + NamespaceID string `json:"namespace_id"` + NamespaceName string `json:"namespace_name"` + NamespaceFqn string `json:"namespace_fqn"` + ValueID string `json:"value_id"` + ValueFqn string `json:"value_fqn"` + ValueActive bool `json:"value_active"` +} + +// 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($1::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($2::text[]) +// ), selected_values AS ( +// SELECT id, attribute_definition_id, active, fqn FROM requested_values +// 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, +// 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 +func (q *Queries) getEntitleableAttributeValues(ctx context.Context, arg getEntitleableAttributeValuesParams) ([]getEntitleableAttributeValuesRow, error) { + rows, err := q.db.Query(ctx, getEntitleableAttributeValues, arg.DefinitionFqns, arg.ValueFqns) + if err != nil { + return nil, err + } + defer rows.Close() + var items []getEntitleableAttributeValuesRow + for rows.Next() { + var i getEntitleableAttributeValuesRow + if err := rows.Scan( + &i.DefinitionID, + &i.DefinitionFqn, + &i.Rule, + &i.AllowTraversal, + &i.NamespaceID, + &i.NamespaceName, + &i.NamespaceFqn, + &i.ValueID, + &i.ValueFqn, + &i.ValueActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/service/policy/db/queries/entitleable_attributes.sql b/service/policy/db/queries/entitleable_attributes.sql new file mode 100644 index 0000000000..8f35a509d4 --- /dev/null +++ b/service/policy/db/queries/entitleable_attributes.sql @@ -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 + 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, + 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;