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
70 changes: 70 additions & 0 deletions service/integration/migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,73 @@ func TestMigrationData_ActionsNamespaceDownRemapsAndDedupes(t *testing.T) {
require.NoError(t, row.Scan(&count))
require.Equal(t, 0, count)
}

// TestMigrationData_ResourceMappingNamespaceBackfill verifies that
// 20260605000000_backfill_resource_mapping_namespace backfills namespace_id on
// existing grouped resource mappings from their group, while leaving ungrouped
// mappings global (NULL).
func TestMigrationData_ResourceMappingNamespaceBackfill(t *testing.T) {
if testing.Short() {
t.Skip("skipping data migration test")
}

h := newMigrationTestHarness(t, "test_opentdf_rm_namespace_backfill")

const (
addNamespaceMigration int64 = 20260604000000
backfillMigration int64 = 20260605000000

namespaceID = "11111111-1111-1111-1111-111111111111"
attributeDefID = "22222222-2222-2222-2222-222222222222"
attributeValueID = "33333333-3333-3333-3333-333333333333"
groupID = "44444444-4444-4444-4444-444444444444"
groupedMappingID = "55555555-5555-5555-5555-555555555555"
ungroupedMappingID = "66666666-6666-6666-6666-666666666666"
)

// Migrate to just after the namespace_id column is added, before the backfill.
h.upTo(addNamespaceMigration)

// Seed the minimal dependency graph and legacy mappings (namespace_id NULL).
h.exec(`INSERT INTO attribute_namespaces (id, name, active) VALUES ($1, 'rm-backfill.example', true)`, namespaceID)
h.exec(`
INSERT INTO attribute_definitions (id, namespace_id, name, rule, active)
VALUES ($1, $2, 'department', 'ALL_OF', true)
`, attributeDefID, namespaceID)
h.exec(`
INSERT INTO attribute_values (id, attribute_definition_id, value, active)
VALUES ($1, $2, 'engineering', true)
`, attributeValueID, attributeDefID)
h.exec(`
INSERT INTO resource_mapping_groups (id, namespace_id, name)
VALUES ($1, $2, 'backfill-group')
`, groupID, namespaceID)
// Grouped legacy mapping with no owning namespace yet.
h.exec(`
INSERT INTO resource_mappings (id, attribute_value_id, terms, group_id)
VALUES ($1, $2, ARRAY['grouped-term'], $3)
`, groupedMappingID, attributeValueID, groupID)
// Ungrouped legacy mapping (no group, no namespace).
h.exec(`
INSERT INTO resource_mappings (id, attribute_value_id, terms)
VALUES ($1, $2, ARRAY['ungrouped-term'])
`, ungroupedMappingID, attributeValueID)

// Precondition: both mappings start with a NULL namespace_id.
var preNamespace *string
require.NoError(t, h.queryRow(`SELECT namespace_id FROM resource_mappings WHERE id = $1`, groupedMappingID).Scan(&preNamespace))
require.Nil(t, preNamespace, "grouped mapping namespace_id should be NULL before backfill")

// Apply the backfill migration.
h.upTo(backfillMigration)

// Grouped mapping is backfilled with its group's namespace.
var groupedNamespace string
require.NoError(t, h.queryRow(`SELECT namespace_id FROM resource_mappings WHERE id = $1`, groupedMappingID).Scan(&groupedNamespace))
require.Equal(t, namespaceID, groupedNamespace, "grouped mapping should be backfilled from its group's namespace")

// Ungrouped mapping has no group to derive from and stays global (NULL).
var ungroupedNamespace *string
require.NoError(t, h.queryRow(`SELECT namespace_id FROM resource_mappings WHERE id = $1`, ungroupedMappingID).Scan(&ungroupedNamespace))
require.Nil(t, ungroupedNamespace, "ungrouped mapping should remain global after backfill")
}
263 changes: 241 additions & 22 deletions service/integration/resource_mappings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,23 +639,209 @@ func (s *ResourceMappingsSuite) Test_CreateResourceMappingWithUnknownGroupIdFail
s.Nil(createdMapping)
}

func (s *ResourceMappingsSuite) Test_CreateResourceMappingGroupNsDiffFromAttrNsFails() {
metadata := &common.MetadataMutable{}
func (s *ResourceMappingsSuite) Test_CreateResourceMapping_GroupNsDiffFromAttrNs_Succeeds() {
// A resource mapping may cross namespaces to the attribute value it maps: the
// mapping is owned by its group's namespace, while the mapped attribute value
// can belong to a different namespace.
ns, group, cleanup := s.createIsolatedNamespaceAndGroup("rm-cross-ns")
defer cleanup()

attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1")
rmGroup := s.getResourceMappingGroupFixtures()[2] // scenario.com_ns_group_1
mapping := &resourcemapping.CreateResourceMappingRequest{
createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Metadata: metadata,
Terms: []string{},
GroupId: rmGroup.ID,
}
createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, mapping)
Metadata: &common.MetadataMutable{},
Terms: []string{"cross-ns-term"},
GroupId: group.GetId(),
})
s.Require().NoError(err)
s.Require().NotNil(createdMapping)
s.Equal(group.GetId(), createdMapping.GetGroup().GetId())
// The mapping is owned by the group's namespace, not the attribute value's namespace.
s.Equal(ns.GetId(), createdMapping.GetGroup().GetNamespaceId())
s.Equal(ns.GetId(), createdMapping.GetNamespace().GetId())
}

func (s *ResourceMappingsSuite) Test_CreateResourceMappingGroup_WithNamespaceFqn_Succeeds() {
ns, cleanup := s.createIsolatedNamespace("rmg-create-fqn")
defer cleanup()

group, err := s.db.PolicyClient.CreateResourceMappingGroup(s.ctx, &resourcemapping.CreateResourceMappingGroupRequest{
NamespaceFqn: ns.GetFqn(),
Name: "rmg-by-fqn",
})
s.Require().NoError(err)
s.Require().NotNil(group)
s.Equal(ns.GetId(), group.GetNamespaceId())
}

func (s *ResourceMappingsSuite) Test_UpdateResourceMappingGroup_WithNamespaceFqn_Succeeds() {
ns1, cleanup1 := s.createIsolatedNamespace("rmg-update-fqn-1")
defer cleanup1()
ns2, cleanup2 := s.createIsolatedNamespace("rmg-update-fqn-2")
defer cleanup2()

group, err := s.db.PolicyClient.CreateResourceMappingGroup(s.ctx, &resourcemapping.CreateResourceMappingGroupRequest{
NamespaceId: ns1.GetId(),
Name: "rmg-update-by-fqn",
})
s.Require().NoError(err)
s.Require().Equal(ns1.GetId(), group.GetNamespaceId())

updated, err := s.db.PolicyClient.UpdateResourceMappingGroup(s.ctx, group.GetId(), &resourcemapping.UpdateResourceMappingGroupRequest{
NamespaceFqn: ns2.GetFqn(),
})
s.Require().NoError(err)
s.Require().NotNil(updated)
s.Equal(ns2.GetId(), updated.GetNamespaceId())
}

func (s *ResourceMappingsSuite) Test_CreateResourceMapping_WithNamespaceId_Succeeds() {
ns, cleanup := s.createIsolatedNamespace("rm-ns-id")
defer cleanup()
attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1")

createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"ns-id-term"},
NamespaceId: ns.GetId(),
})
s.Require().NoError(err)
s.Require().NotNil(createdMapping)
s.Nil(createdMapping.GetGroup())
s.Equal(ns.GetId(), createdMapping.GetNamespace().GetId())
}

func (s *ResourceMappingsSuite) Test_CreateResourceMapping_WithNamespaceFqn_Succeeds() {
ns, cleanup := s.createIsolatedNamespace("rm-ns-fqn")
defer cleanup()
attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1")

createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"ns-fqn-term"},
NamespaceFqn: ns.GetFqn(),
})
s.Require().NoError(err)
s.Require().NotNil(createdMapping)
s.Equal(ns.GetId(), createdMapping.GetNamespace().GetId())
s.Equal(ns.GetFqn(), createdMapping.GetNamespace().GetFqn())
}

func (s *ResourceMappingsSuite) Test_CreateResourceMapping_WithGroupAndMatchingNamespaceId_Succeeds() {
ns, group, cleanup := s.createIsolatedNamespaceAndGroup("rm-group-match")
defer cleanup()
attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1")

createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"group-ns-match-term"},
GroupId: group.GetId(),
NamespaceId: ns.GetId(),
})
s.Require().NoError(err)
s.Require().NotNil(createdMapping)
s.Equal(ns.GetId(), createdMapping.GetNamespace().GetId())
}

func (s *ResourceMappingsSuite) Test_CreateResourceMapping_WithGroupAndMismatchedNamespaceId_Fails() {
_, group, cleanup := s.createIsolatedNamespaceAndGroup("rm-group-mismatch")
defer cleanup()
attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1")
otherNs := s.getExampleDotComNamespace()

createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"group-ns-mismatch-term"},
GroupId: group.GetId(),
NamespaceId: otherNs.ID,
})
s.Require().Error(err)
s.Require().ErrorIs(err, db.ErrNamespaceMismatch)
s.Nil(createdMapping)
}

func (s *ResourceMappingsSuite) Test_ListResourceMappings_FilterByNamespaceId_Succeeds() {
nsA, cleanupA := s.createIsolatedNamespace("rm-list-ns-id-a")
defer cleanupA()
nsB, cleanupB := s.createIsolatedNamespace("rm-list-ns-id-b")
defer cleanupB()
attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1")

createdA, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"list-ns-id-term-a"},
NamespaceId: nsA.GetId(),
})
s.Require().NoError(err)

// A second mapping in a different namespace must be excluded by the filter.
_, err = s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"list-ns-id-term-b"},
NamespaceId: nsB.GetId(),
})
s.Require().NoError(err)

listRsp, err := s.db.PolicyClient.ListResourceMappings(s.ctx, &resourcemapping.ListResourceMappingsRequest{
NamespaceId: nsA.GetId(),
})
s.Require().NoError(err)
s.Require().NotNil(listRsp)

s.Len(listRsp.GetResourceMappings(), 1, "filter should return only the mapping owned by namespace A")
s.Equal(createdA.GetId(), listRsp.GetResourceMappings()[0].GetId())
s.Equal(nsA.GetId(), listRsp.GetResourceMappings()[0].GetNamespace().GetId())
}

func (s *ResourceMappingsSuite) Test_ListResourceMappings_FilterByNamespaceFqn_Succeeds() {
nsA, cleanupA := s.createIsolatedNamespace("rm-list-ns-fqn-a")
defer cleanupA()
nsB, cleanupB := s.createIsolatedNamespace("rm-list-ns-fqn-b")
defer cleanupB()
attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1")

createdA, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"list-ns-fqn-term-a"},
NamespaceFqn: nsA.GetFqn(),
})
s.Require().NoError(err)

// A second mapping in a different namespace must be excluded by the filter.
_, err = s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"list-ns-fqn-term-b"},
NamespaceFqn: nsB.GetFqn(),
})
s.Require().NoError(err)

listRsp, err := s.db.PolicyClient.ListResourceMappings(s.ctx, &resourcemapping.ListResourceMappingsRequest{
NamespaceFqn: nsA.GetFqn(),
})
s.Require().NoError(err)
s.Require().NotNil(listRsp)

s.Len(listRsp.GetResourceMappings(), 1, "filter should return only the mapping owned by namespace A")
s.Equal(createdA.GetId(), listRsp.GetResourceMappings()[0].GetId())
s.Equal(nsA.GetFqn(), listRsp.GetResourceMappings()[0].GetNamespace().GetFqn())
}

func (s *ResourceMappingsSuite) Test_ListResourceMappingGroups_WithNamespaceFqn_Succeeds() {
ns, group, cleanup := s.createIsolatedNamespaceAndGroup("rmg-list-ns-fqn")
defer cleanup()

listRsp, err := s.db.PolicyClient.ListResourceMappingGroups(s.ctx, &resourcemapping.ListResourceMappingGroupsRequest{
NamespaceFqn: ns.GetFqn(),
})
s.Require().NoError(err)
s.Require().NotNil(listRsp)

list := listRsp.GetResourceMappingGroups()
s.Len(list, 1, "isolated namespace should own exactly one group")
s.Equal(group.GetId(), list[0].GetId())
s.Equal(ns.GetId(), list[0].GetNamespaceId())
}

func (s *ResourceMappingsSuite) Test_ListResourceMappings_NoPagination_Succeeds() {
testMappings := make(map[string]fixtures.FixtureDataResourceMapping)
for _, testMapping := range s.getResourceMappingFixtures() {
Expand Down Expand Up @@ -1421,27 +1607,30 @@ func (s *ResourceMappingsSuite) Test_UpdateResourceMappingWithUnknownGroupIdFail
s.Nil(updated)
}

func (s *ResourceMappingsSuite) Test_UpdateResourceMappingWithGroupNsDiffFromAttrNsFails() {
func (s *ResourceMappingsSuite) Test_UpdateResourceMapping_GroupNsDiffFromAttrNs_Succeeds() {
// Moving a mapping into a group whose namespace differs from the mapped
// attribute value's namespace is allowed; the mapping adopts the group's
// owning namespace.
ns, group, cleanup := s.createIsolatedNamespaceAndGroup("rm-update-cross-ns")
defer cleanup()

attrValue := s.f.GetAttributeValueKey("example.com/attr/attr2/value/value2")
mapping := &resourcemapping.CreateResourceMappingRequest{
createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{
AttributeValueId: attrValue.ID,
Terms: []string{"asdf qwerty"},
}
createdMapping, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, mapping)
})
s.Require().NoError(err)
s.NotNil(createdMapping)

rmGroup := s.getResourceMappingGroupFixtures()[2] // scenario.com_ns_group_1
// update the created with new metadata, terms and unknown group ID
updatedMapping := &resourcemapping.UpdateResourceMappingRequest{
updated, err := s.db.PolicyClient.UpdateResourceMapping(s.ctx, createdMapping.GetId(), &resourcemapping.UpdateResourceMappingRequest{
AttributeValueId: createdMapping.GetAttributeValue().GetId(),
Terms: []string{"asdf updated term1"},
GroupId: rmGroup.ID,
}
updated, err := s.db.PolicyClient.UpdateResourceMapping(s.ctx, createdMapping.GetId(), updatedMapping)
s.Require().Error(err)
s.Require().ErrorIs(err, db.ErrNamespaceMismatch)
s.Nil(updated)
GroupId: group.GetId(),
})
s.Require().NoError(err)
s.Require().NotNil(updated)
s.Equal(group.GetId(), updated.GetGroup().GetId())
s.Equal(ns.GetId(), updated.GetNamespace().GetId())
}

func (s *ResourceMappingsSuite) Test_DeleteResourceMapping() {
Expand Down Expand Up @@ -1480,6 +1669,36 @@ func (s *ResourceMappingsSuite) getScenarioDotComNamespace() *fixtures.FixtureDa
return &namespace
}

// createIsolatedNamespace creates a fresh namespace for a single test so that
// owning resource mappings/groups do not pollute the shared fixtures. The
// returned cleanup deletes the namespace (cascading to anything owned by it).
func (s *ResourceMappingsSuite) createIsolatedNamespace(label string) (*policy.Namespace, func()) {
suffix := time.Now().UnixNano()
ns, err := s.db.PolicyClient.CreateNamespace(s.ctx, &namespaces.CreateNamespaceRequest{
Name: fmt.Sprintf("%s-%d.com", label, suffix),
})
s.Require().NoError(err)
s.Require().NotNil(ns)
return ns, func() {
_, err := s.db.PolicyClient.UnsafeDeleteNamespace(s.ctx, ns, ns.GetFqn())
s.Require().NoError(err)
}
}

// createIsolatedNamespaceAndGroup creates a fresh namespace and a resource
// mapping group within it for a single test. The returned cleanup deletes the
// namespace (cascading to the group and any owned mappings).
func (s *ResourceMappingsSuite) createIsolatedNamespaceAndGroup(label string) (*policy.Namespace, *policy.ResourceMappingGroup, func()) {
ns, cleanup := s.createIsolatedNamespace(label)
group, err := s.db.PolicyClient.CreateResourceMappingGroup(s.ctx, &resourcemapping.CreateResourceMappingGroupRequest{
Name: label + "-group",
NamespaceId: ns.GetId(),
})
s.Require().NoError(err)
s.Require().NotNil(group)
return ns, group, cleanup
}

func (s *ResourceMappingsSuite) getResourceMappingGroupFixtures() []fixtures.FixtureDataResourceMappingGroup {
return []fixtures.FixtureDataResourceMappingGroup{
s.f.GetResourceMappingGroupKey("example.com_ns_group_1"),
Expand Down
Loading
Loading