Skip to content
Merged
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
301 changes: 301 additions & 0 deletions service/integration/subject_mappings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/opentdf/platform/protocol/go/common"
"github.com/opentdf/platform/protocol/go/policy"
"github.com/opentdf/platform/protocol/go/policy/actions"
"github.com/opentdf/platform/protocol/go/policy/attributes"
"github.com/opentdf/platform/protocol/go/policy/namespaces"
"github.com/opentdf/platform/protocol/go/policy/subjectmapping"
"github.com/opentdf/platform/service/internal/fixtures"
Expand Down Expand Up @@ -1040,6 +1041,237 @@ func (s *SubjectMappingsSuite) Test_ListSubjectMappings_NoNamespaceFilter_Return
s.True(foundUnnamespaced)
}

func (s *SubjectMappingsSuite) Test_ListSubjectMappings_SearchByAttributeValueFQNAndLabels_Succeeds() {
suffix := time.Now().UnixNano()
attrName := fmt.Sprintf("list-sm-search-attr-%d", suffix)
valueToken := fmt.Sprintf("fqn-only-%d", suffix)
ns, values := s.createSearchSubjectMappingNamespace(suffix, []string{
valueToken,
fmt.Sprintf("label-holder-%d", suffix),
fmt.Sprintf("label-key-holder-%d", suffix),
fmt.Sprintf("condition-holder-%d", suffix),
fmt.Sprintf("unmatched-%d", suffix),
})
s.T().Cleanup(func() {
s.deleteSearchSubjectMappingNamespace(ns)
})

actionToken := fmt.Sprintf("action-only-%d", suffix)
labelToken := fmt.Sprintf("label-only-%d", suffix)
labelKeyToken := fmt.Sprintf("label-key-only-%d", suffix)
selectorToken := fmt.Sprintf(".selector-only-%d", suffix)
externalToken := fmt.Sprintf("external-only-%d@example.com", suffix)

fqnSM := s.createSearchSubjectMapping(ns.GetId(), values[0].GetId(), ".fqn-holder", []string{"fqn-holder@example.com"}, "read", nil)
labelSM := s.createSearchSubjectMapping(ns.GetId(), values[1].GetId(), ".label-holder", []string{"label-holder@example.com"}, actionToken, map[string]string{
"search-label": labelToken,
"other-label": fmt.Sprintf("other-label-%d", suffix),
})
labelKeySM := s.createSearchSubjectMapping(ns.GetId(), values[2].GetId(), ".label-key-holder", []string{"label-key-holder@example.com"}, "read", map[string]string{labelKeyToken: "not-searchable-by-key"})
conditionSM := s.createSearchSubjectMapping(ns.GetId(), values[3].GetId(), selectorToken, []string{externalToken}, "read", nil)
unmatchedSM := s.createSearchSubjectMapping(ns.GetId(), values[4].GetId(), ".unmatched", []string{"unmatched@example.com"}, "read", map[string]string{"unused": fmt.Sprintf("unused-%d", suffix)})

tests := []struct {
name string
term string
ids []string
}{
{name: "attribute value fqn", term: strings.ToUpper(values[0].GetFqn()), ids: []string{fqnSM.GetId()}},
{name: "attribute name in fqn", term: attrName, ids: []string{fqnSM.GetId(), labelSM.GetId(), labelKeySM.GetId(), conditionSM.GetId(), unmatchedSM.GetId()}},
{name: "attribute value in fqn", term: valueToken, ids: []string{fqnSM.GetId()}},
{name: "metadata label value among multiple labels", term: strings.ToUpper(labelToken), ids: []string{labelSM.GetId()}},
}

for _, tc := range tests {
s.Run(tc.name, func() {
list, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
Search: &policy.Search{Term: tc.term},
})
s.Require().NoError(err)
gotIDs := make([]string, 0, len(list.GetSubjectMappings()))
for _, sm := range list.GetSubjectMappings() {
gotIDs = append(gotIDs, sm.GetId())
}
s.ElementsMatch(tc.ids, gotIDs)
s.Equal(int32(len(tc.ids)), list.GetPagination().GetTotal())
})
}

// ListSubjectMappings search is intentionally limited to attribute value FQNs
// and metadata label values; action names, label keys, and subject condition
// selectors/external values should not affect filtered results.
for _, term := range []string{
Comment thread
c-r33d marked this conversation as resolved.
actionToken,
labelKeyToken,
selectorToken,
externalToken,
} {
list, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
Search: &policy.Search{Term: term},
})
s.Require().NoError(err)
s.Empty(list.GetSubjectMappings())
s.Equal(int32(0), list.GetPagination().GetTotal())
}
}

func (s *SubjectMappingsSuite) Test_ListSubjectMappings_SearchTrimsWhitespace_Succeeds() {
suffix := time.Now().UnixNano()
labelToken := fmt.Sprintf("trimmed-label-%d", suffix)

ns, values := s.createSearchSubjectMappingNamespace(suffix, []string{fmt.Sprintf("trimmed-%d", suffix)})
s.T().Cleanup(func() {
s.deleteSearchSubjectMappingNamespace(ns)
})

sm := s.createSearchSubjectMapping(ns.GetId(), values[0].GetId(), ".trimmed", []string{"trimmed@example.com"}, "read", map[string]string{"search-label": labelToken})

list, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
Search: &policy.Search{Term: " " + labelToken + " "},
})
s.Require().NoError(err)
s.Require().Len(list.GetSubjectMappings(), 1)
s.Equal(sm.GetId(), list.GetSubjectMappings()[0].GetId())
s.Equal(int32(1), list.GetPagination().GetTotal())
}

func (s *SubjectMappingsSuite) Test_ListSubjectMappings_SearchEscapesLikeWildcardLiterals_Succeeds() {
suffix := time.Now().UnixNano()

ns, values := s.createSearchSubjectMappingNamespace(suffix, []string{
fmt.Sprintf("wildcarda-%d", suffix),
fmt.Sprintf("wildcardb-%d", suffix),
})
s.T().Cleanup(func() {
s.deleteSearchSubjectMappingNamespace(ns)
})

s.createSearchSubjectMapping(ns.GetId(), values[0].GetId(), ".wildcard-a", []string{"wildcard-a@example.com"}, "read", map[string]string{"wildcard": fmt.Sprintf("wildcarda-%d", suffix)})
s.createSearchSubjectMapping(ns.GetId(), values[1].GetId(), ".wildcard-b", []string{"wildcard-b@example.com"}, "read", map[string]string{"wildcard": fmt.Sprintf("wildcardb-%d", suffix)})

for _, term := range []string{
fmt.Sprintf("wildcard_-%d", suffix),
fmt.Sprintf("wildcard%%-%d", suffix),
} {
list, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
Search: &policy.Search{Term: term},
})
s.Require().NoError(err)
s.Empty(list.GetSubjectMappings())
s.Equal(int32(0), list.GetPagination().GetTotal())
}
}

func (s *SubjectMappingsSuite) Test_ListSubjectMappings_SearchCombinesWithNamespace_Succeeds() {
suffix := time.Now().UnixNano()
searchToken := fmt.Sprintf("namespace-and-search-%d", suffix)

firstNS, firstValues := s.createSearchSubjectMappingNamespace(suffix, []string{fmt.Sprintf("first-%d", suffix)})
s.T().Cleanup(func() {
s.deleteSearchSubjectMappingNamespace(firstNS)
})
secondNS, secondValues := s.createSearchSubjectMappingNamespace(suffix+1, []string{fmt.Sprintf("second-%d", suffix)})
s.T().Cleanup(func() {
s.deleteSearchSubjectMappingNamespace(secondNS)
})

firstSM := s.createSearchSubjectMapping(firstNS.GetId(), firstValues[0].GetId(), ".first", []string{"first@example.com"}, "read", map[string]string{"search": searchToken})
secondSM := s.createSearchSubjectMapping(secondNS.GetId(), secondValues[0].GetId(), ".second", []string{"second@example.com"}, "read", map[string]string{"search": searchToken})

byFirstNS, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: firstNS.GetId(),
Search: &policy.Search{Term: searchToken},
})
s.Require().NoError(err)
s.Require().Len(byFirstNS.GetSubjectMappings(), 1)
s.Equal(firstSM.GetId(), byFirstNS.GetSubjectMappings()[0].GetId())
s.Equal(int32(1), byFirstNS.GetPagination().GetTotal())

bySecondFQN, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceFqn: secondNS.GetFqn(),
Search: &policy.Search{Term: searchToken},
})
s.Require().NoError(err)
s.Require().Len(bySecondFQN.GetSubjectMappings(), 1)
s.Equal(secondSM.GetId(), bySecondFQN.GetSubjectMappings()[0].GetId())
s.Equal(int32(1), bySecondFQN.GetPagination().GetTotal())
}

func (s *SubjectMappingsSuite) Test_ListSubjectMappings_SearchEmptyQuery_Succeeds() {
suffix := time.Now().UnixNano()
ns, values := s.createSearchSubjectMappingNamespace(suffix, []string{fmt.Sprintf("empty-search-%d", suffix)})
s.T().Cleanup(func() {
s.deleteSearchSubjectMappingNamespace(ns)
})
s.createSearchSubjectMapping(ns.GetId(), values[0].GetId(), ".empty-search", []string{"empty-search@example.com"}, "read", nil)

noSearch, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
})
s.Require().NoError(err)
emptySearch, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
Search: &policy.Search{Term: ""},
})
s.Require().NoError(err)
s.Equal(noSearch.GetPagination().GetTotal(), emptySearch.GetPagination().GetTotal())
s.Len(emptySearch.GetSubjectMappings(), len(noSearch.GetSubjectMappings()))
}

func (s *SubjectMappingsSuite) Test_ListSubjectMappings_SearchPaginationAppliesAfterFiltering_Succeeds() {
Comment thread
c-r33d marked this conversation as resolved.
suffix := time.Now().UnixNano()
searchToken := fmt.Sprintf("search-page-%d", suffix)
ns, values := s.createSearchSubjectMappingNamespace(suffix, []string{
fmt.Sprintf("page-a-%d", suffix),
fmt.Sprintf("page-b-%d", suffix),
fmt.Sprintf("page-c-%d", suffix),
fmt.Sprintf("page-other-%d", suffix),
})
s.T().Cleanup(func() {
s.deleteSearchSubjectMappingNamespace(ns)
})

ids := make([]string, 3)
for i := range ids {
ids[i] = s.createSearchSubjectMapping(ns.GetId(), values[i].GetId(), fmt.Sprintf(".page-%d", i), []string{fmt.Sprintf("page-%d@example.com", i)}, "read", map[string]string{"search": searchToken}).GetId()
}
s.createSearchSubjectMapping(ns.GetId(), values[3].GetId(), ".page-other", []string{"page-other@example.com"}, "read", map[string]string{"other": fmt.Sprintf("other-%d", suffix)})

firstPage, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
Search: &policy.Search{Term: searchToken},
Pagination: &policy.PageRequest{Limit: 2},
Sort: []*subjectmapping.SubjectMappingsSort{
{Field: subjectmapping.SortSubjectMappingsType_SORT_SUBJECT_MAPPINGS_TYPE_CREATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_ASC},
},
})
s.Require().NoError(err)
s.Require().Len(firstPage.GetSubjectMappings(), 2)
s.Equal(int32(3), firstPage.GetPagination().GetTotal())
s.Equal(int32(2), firstPage.GetPagination().GetNextOffset())
s.Equal(ids[0], firstPage.GetSubjectMappings()[0].GetId())
s.Equal(ids[1], firstPage.GetSubjectMappings()[1].GetId())

secondPage, err := s.db.PolicyClient.ListSubjectMappings(s.ctx, &subjectmapping.ListSubjectMappingsRequest{
NamespaceId: ns.GetId(),
Search: &policy.Search{Term: searchToken},
Pagination: &policy.PageRequest{Limit: 2, Offset: 2},
Sort: []*subjectmapping.SubjectMappingsSort{
{Field: subjectmapping.SortSubjectMappingsType_SORT_SUBJECT_MAPPINGS_TYPE_CREATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_ASC},
},
})
s.Require().NoError(err)
s.Require().Len(secondPage.GetSubjectMappings(), 1)
s.Equal(int32(3), secondPage.GetPagination().GetTotal())
s.Equal(int32(2), secondPage.GetPagination().GetCurrentOffset())
s.Equal(int32(0), secondPage.GetPagination().GetNextOffset())
s.Equal(ids[2], secondPage.GetSubjectMappings()[0].GetId())
}

func (s *SubjectMappingsSuite) TestDeleteSubjectMapping() {
// create a new subject mapping, delete it, and verify get fails with not found
fixtureAttrValID := s.f.GetAttributeValueKey("example.com/attr/attr2/value/value1").ID
Expand Down Expand Up @@ -3075,6 +3307,75 @@ func (s *SubjectMappingsSuite) createSortTestSubjectConditionSets(prefixes []str
return ids
}

func (s *SubjectMappingsSuite) createSearchSubjectMappingNamespace(suffix int64, values []string) (*policy.Namespace, []*policy.Value) {
ns, err := s.db.PolicyClient.CreateNamespace(s.ctx, &namespaces.CreateNamespaceRequest{
Name: fmt.Sprintf("list-sm-search-%d.example", suffix),
})
s.Require().NoError(err)
s.Require().NotNil(ns)

attr, err := s.db.PolicyClient.CreateAttribute(s.ctx, &attributes.CreateAttributeRequest{
Name: fmt.Sprintf("list-sm-search-attr-%d", suffix),
NamespaceId: ns.GetId(),
Rule: policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF,
})
s.Require().NoError(err)
s.Require().NotNil(attr)

createdValues := make([]*policy.Value, len(values))
for i, value := range values {
created, err := s.db.PolicyClient.CreateAttributeValue(s.ctx, attr.GetId(), &attributes.CreateAttributeValueRequest{
Value: value,
})
s.Require().NoError(err)
createdValues[i] = created
}

return ns, createdValues
}

func (s *SubjectMappingsSuite) createSearchSubjectMapping(
namespaceID string,
attributeValueID string,
selector string,
externalValues []string,
actionName string,
labels map[string]string,
) *policy.SubjectMapping {
created, err := s.db.PolicyClient.CreateSubjectMapping(s.ctx, &subjectmapping.CreateSubjectMappingRequest{
NamespaceId: namespaceID,
AttributeValueId: attributeValueID,
Actions: []*policy.Action{{Name: actionName}},
Metadata: &common.MetadataMutable{Labels: labels},
NewSubjectConditionSet: &subjectmapping.SubjectConditionSetCreate{
SubjectSets: []*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: externalValues,
},
},
},
},
},
},
},
})
s.Require().NoError(err)
s.Require().NotNil(created)
return created
}

func (s *SubjectMappingsSuite) deleteSearchSubjectMappingNamespace(ns *policy.Namespace) {
_, err := s.db.PolicyClient.UnsafeDeleteNamespace(s.ctx, ns, ns.GetFqn())
s.Require().NoError(err)
}

// deleteSortTestSubjectMappings cleans up subject mappings created by sort tests.
func (s *SubjectMappingsSuite) deleteSortTestSubjectMappings(ids []string) {
for _, id := range ids {
Expand Down
33 changes: 24 additions & 9 deletions service/policy/db/queries/subject_mappings.sql
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,33 @@ subject_actions AS (
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
GROUP BY sma.subject_mapping_id
), counted AS (
SELECT COUNT(sm.id) AS total
), filtered_subject_mappings AS (
SELECT DISTINCT sm.id
FROM subject_mappings sm
LEFT JOIN attribute_values av ON sm.attribute_value_id = av.id
LEFT JOIN attribute_fqns fqns ON av.id = fqns.value_id
LEFT JOIN attribute_namespaces sm_ns ON sm_ns.id = sm.namespace_id
LEFT JOIN attribute_fqns sm_ns_fqns ON sm_ns_fqns.namespace_id = sm_ns.id AND sm_ns_fqns.attribute_id IS NULL AND sm_ns_fqns.value_id IS NULL
WHERE
(sqlc.narg('namespace_id')::uuid IS NULL AND sqlc.narg('namespace_fqn')::text IS NULL)
OR sm.namespace_id = sqlc.narg('namespace_id')::uuid
OR sm_ns_fqns.fqn = sqlc.narg('namespace_fqn')::text
(
(sqlc.narg('namespace_id')::uuid IS NULL AND sqlc.narg('namespace_fqn')::text IS NULL)
OR sm.namespace_id = sqlc.narg('namespace_id')::uuid
OR sm_ns_fqns.fqn = sqlc.narg('namespace_fqn')::text
)
AND CASE
WHEN sqlc.narg('search')::TEXT IS NULL THEN TRUE
ELSE (
fqns.fqn LIKE sqlc.narg('search')::TEXT ESCAPE '\'
OR EXISTS (
SELECT 1
FROM JSONB_EACH_TEXT(COALESCE(sm.metadata -> 'labels', '{}'::JSONB)) AS label(key, value)
WHERE label.value ILIKE sqlc.narg('search')::TEXT ESCAPE '\'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
)
END
), counted AS (
SELECT COUNT(id) AS total
FROM filtered_subject_mappings
)
SELECT
sm.id,
Expand Down Expand Up @@ -152,6 +170,7 @@ SELECT
END AS namespace,
counted.total
FROM subject_mappings sm
JOIN filtered_subject_mappings fsm ON fsm.id = sm.id
CROSS JOIN counted
CROSS JOIN params p
LEFT JOIN subject_actions sa ON sm.id = sa.subject_mapping_id
Expand All @@ -162,10 +181,6 @@ LEFT JOIN attribute_namespaces scs_ns ON scs_ns.id = scs.namespace_id
LEFT JOIN attribute_fqns scs_ns_fqns ON scs_ns_fqns.namespace_id = scs_ns.id AND scs_ns_fqns.attribute_id IS NULL AND scs_ns_fqns.value_id IS NULL
LEFT JOIN attribute_namespaces sm_ns ON sm_ns.id = sm.namespace_id
LEFT JOIN attribute_fqns sm_ns_fqns ON sm_ns_fqns.namespace_id = sm_ns.id AND sm_ns_fqns.attribute_id IS NULL AND sm_ns_fqns.value_id IS NULL
WHERE
(sqlc.narg('namespace_id')::uuid IS NULL AND sqlc.narg('namespace_fqn')::text IS NULL)
OR sm.namespace_id = sqlc.narg('namespace_id')::uuid
OR sm_ns_fqns.fqn = sqlc.narg('namespace_fqn')::text
GROUP BY
sm.id,
sa.standard_actions,
Expand Down
2 changes: 2 additions & 0 deletions service/policy/db/subject_mappings.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,10 +361,12 @@ func (c PolicyDBClient) ListSubjectMappings(ctx context.Context, r *subjectmappi
}

sortField, sortDirection := GetSubjectMappingsSortParams(r.GetSort())
search := pgtypeSubstringSearchPattern(r.GetSearch().GetTerm())

list, err := c.queries.listSubjectMappings(ctx, listSubjectMappingsParams{
NamespaceID: pgtypeUUID(r.GetNamespaceId()),
NamespaceFqn: pgtypeText(r.GetNamespaceFqn()),
Search: search,
Limit: limit,
Offset: offset,
SortField: sortField,
Expand Down
Loading
Loading