diff --git a/service/integration/migration_test.go b/service/integration/migration_test.go index 4a8529308f..7405936791 100644 --- a/service/integration/migration_test.go +++ b/service/integration/migration_test.go @@ -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") +} diff --git a/service/integration/resource_mappings_test.go b/service/integration/resource_mappings_test.go index b11c5b48b1..961658e0a1 100644 --- a/service/integration/resource_mappings_test.go +++ b/service/integration/resource_mappings_test.go @@ -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() { @@ -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() { @@ -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"), diff --git a/service/policy/db/migrations/20260604000000_add_namespace_to_resource_mappings.sql b/service/policy/db/migrations/20260604000000_add_namespace_to_resource_mappings.sql new file mode 100644 index 0000000000..eeab960936 --- /dev/null +++ b/service/policy/db/migrations/20260604000000_add_namespace_to_resource_mappings.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- +goose StatementBegin + +-- Add a nullable owning namespace to resource mappings so they can be optionally +-- namespaced (owned by a tenant), independent of the namespace of the attribute +-- value they map. When a mapping belongs to a group, this matches the group's +-- namespace; ungrouped/legacy mappings leave it NULL. +ALTER TABLE resource_mappings + ADD COLUMN namespace_id UUID REFERENCES attribute_namespaces(id) ON DELETE CASCADE; + +COMMENT ON COLUMN resource_mappings.namespace_id IS 'Optional owning namespace of the resource mapping. If the mapping belongs to a group, it matches the group namespace. The mapped attribute value may belong to a different namespace.'; + +-- Index for namespace-scoped resource mapping queries. +CREATE INDEX idx_resource_mappings_namespace_id + ON resource_mappings(namespace_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP INDEX IF EXISTS idx_resource_mappings_namespace_id; + +ALTER TABLE resource_mappings DROP COLUMN IF EXISTS namespace_id; + +-- +goose StatementEnd diff --git a/service/policy/db/migrations/20260605000000_backfill_resource_mapping_namespace.sql b/service/policy/db/migrations/20260605000000_backfill_resource_mapping_namespace.sql new file mode 100644 index 0000000000..82448066b0 --- /dev/null +++ b/service/policy/db/migrations/20260605000000_backfill_resource_mapping_namespace.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin + +-- Backfill the owning namespace for existing grouped resource mappings created +-- before resource_mappings.namespace_id existed, so that namespace filtering +-- works on legacy data. A grouped mapping is owned by its group's namespace. +-- Ungrouped mappings have no group to derive ownership from and remain global +-- (namespace_id stays NULL). Idempotent: only fills rows that are still NULL. +UPDATE resource_mappings m +SET namespace_id = g.namespace_id +FROM resource_mapping_groups g +WHERE m.group_id = g.id + AND m.namespace_id IS NULL; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- No-op: a backfilled namespace_id cannot be reliably distinguished from one set +-- intentionally after this migration, so the backfill is not reverted. The +-- column itself is removed by the add-namespace migration's down step. +SELECT 1; + +-- +goose StatementEnd diff --git a/service/policy/db/models.go b/service/policy/db/models.go index fb0616f43f..a53ae673b3 100644 --- a/service/policy/db/models.go +++ b/service/policy/db/models.go @@ -397,6 +397,8 @@ type ResourceMapping struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` // Foreign key to the parent group of the resource mapping (optional, a resource mapping may not be in a group) GroupID pgtype.UUID `json:"group_id"` + // Optional owning namespace of the resource mapping. If the mapping belongs to a group, it matches the group namespace. The mapped attribute value may belong to a different namespace. + NamespaceID pgtype.UUID `json:"namespace_id"` } // Table to store the groups of resource mappings by unique namespace and group name combinations diff --git a/service/policy/db/queries/resource_mapping.sql b/service/policy/db/queries/resource_mapping.sql index 92611c909a..c6159d4d9d 100644 --- a/service/policy/db/queries/resource_mapping.sql +++ b/service/policy/db/queries/resource_mapping.sql @@ -11,10 +11,12 @@ SELECT rmg.id, COUNT(*) OVER() AS total FROM resource_mapping_groups rmg JOIN attribute_namespaces ns ON rmg.namespace_id = ns.id -WHERE (sqlc.narg('namespace_id')::uuid IS NULL OR rmg.namespace_id = sqlc.narg('namespace_id')::uuid) +LEFT JOIN attribute_fqns ns_fqn ON ns_fqn.namespace_id = ns.id AND ns_fqn.attribute_id IS NULL AND ns_fqn.value_id IS NULL +WHERE (sqlc.narg('namespace_id')::uuid IS NULL OR rmg.namespace_id = sqlc.narg('namespace_id')::uuid) + AND (sqlc.narg('namespace_fqn')::text IS NULL OR ns_fqn.fqn = sqlc.narg('namespace_fqn')::text) ORDER BY rmg.created_at DESC -LIMIT @limit_ -OFFSET @offset_; +LIMIT @limit_ +OFFSET @offset_; -- name: getResourceMappingGroup :one SELECT rmg.id, @@ -61,17 +63,29 @@ SELECT 'fqn', CONCAT('https://', rmg_ns.name, '/resm/', rmg.name)::TEXT ) END)::JSON AS group, + (CASE + WHEN m.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT( + 'id', rm_ns.id, + 'name', rm_ns.name, + 'fqn', rm_ns_fqn.fqn + ) + END)::JSON AS namespace, COUNT(*) OVER() AS total FROM resource_mappings m LEFT JOIN attribute_values av on m.attribute_value_id = av.id LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id LEFT JOIN resource_mapping_groups rmg ON m.group_id = rmg.id LEFT JOIN attribute_namespaces rmg_ns ON rmg.namespace_id = rmg_ns.id +LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL WHERE (sqlc.narg('group_id')::uuid IS NULL OR m.group_id = sqlc.narg('group_id')::uuid) -GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name + AND (sqlc.narg('namespace_id')::uuid IS NULL OR m.namespace_id = sqlc.narg('namespace_id')::uuid) + AND (sqlc.narg('namespace_fqn')::text IS NULL OR rm_ns_fqn.fqn = sqlc.narg('namespace_fqn')::text) +GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name, rm_ns.id, rm_ns_fqn.fqn ORDER BY m.created_at DESC -LIMIT @limit_ -OFFSET @offset_; +LIMIT @limit_ +OFFSET @offset_; -- name: listResourceMappingsByFullyQualifiedGroup :many -- CTE to cache the group JSON build since it will be the same for all mappings of the group @@ -98,11 +112,21 @@ SELECT JSON_BUILD_OBJECT('id', av.id, 'value', av.value, 'fqn', fqns.fqn) as attribute_value, m.terms, JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', m.metadata -> 'labels', 'created_at', m.created_at, 'updated_at', m.updated_at)) as metadata, - g.group + g.group, + (CASE + WHEN m.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT( + 'id', rm_ns.id, + 'name', rm_ns.name, + 'fqn', rm_ns_fqn.fqn + ) + END)::JSON AS namespace FROM resource_mappings m JOIN groups_cte g ON m.group_id = g.id JOIN attribute_values av on m.attribute_value_id = av.id JOIN attribute_fqns fqns on av.id = fqns.value_id +LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL ORDER BY m.created_at DESC; -- name: getResourceMapping :one @@ -119,18 +143,28 @@ SELECT 'namespace_id', rmg.namespace_id, 'fqn', CONCAT('https://', rmg_ns.name, '/resm/', rmg.name)::TEXT ) - END)::JSON AS group -FROM resource_mappings m + END)::JSON AS group, + (CASE + WHEN m.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT( + 'id', rm_ns.id, + 'name', rm_ns.name, + 'fqn', rm_ns_fqn.fqn + ) + END)::JSON AS namespace +FROM resource_mappings m LEFT JOIN attribute_values av on m.attribute_value_id = av.id LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id LEFT JOIN resource_mapping_groups rmg ON m.group_id = rmg.id LEFT JOIN attribute_namespaces rmg_ns ON rmg.namespace_id = rmg_ns.id +LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL WHERE m.id = $1 -GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name; +GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name, rm_ns.id, rm_ns_fqn.fqn; -- name: createResourceMapping :one -INSERT INTO resource_mappings (attribute_value_id, terms, metadata, group_id) -VALUES ($1, $2, $3, $4) +INSERT INTO resource_mappings (attribute_value_id, terms, metadata, group_id, namespace_id) +VALUES ($1, $2, $3, $4, $5) RETURNING id; -- name: updateResourceMapping :execrows @@ -139,7 +173,8 @@ SET attribute_value_id = COALESCE(sqlc.narg('attribute_value_id'), attribute_value_id), terms = COALESCE(sqlc.narg('terms'), terms), metadata = COALESCE(sqlc.narg('metadata'), metadata), - group_id = COALESCE(sqlc.narg('group_id'), group_id) + group_id = COALESCE(sqlc.narg('group_id'), group_id), + namespace_id = COALESCE(sqlc.narg('namespace_id'), namespace_id) WHERE id = $1; -- name: deleteResourceMapping :execrows diff --git a/service/policy/db/resource_mapping.go b/service/policy/db/resource_mapping.go index beda72cd01..66007aa17f 100644 --- a/service/policy/db/resource_mapping.go +++ b/service/policy/db/resource_mapping.go @@ -8,6 +8,7 @@ import ( "github.com/opentdf/platform/lib/identifier" "github.com/opentdf/platform/protocol/go/common" "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/namespaces" "github.com/opentdf/platform/protocol/go/policy/resourcemapping" "github.com/opentdf/platform/service/pkg/db" "google.golang.org/protobuf/encoding/protojson" @@ -26,9 +27,10 @@ func (c PolicyDBClient) ListResourceMappingGroups(ctx context.Context, r *resour } list, err := c.queries.listResourceMappingGroups(ctx, listResourceMappingGroupsParams{ - NamespaceID: pgtypeUUID(r.GetNamespaceId()), - Limit: limit, - Offset: offset, + NamespaceID: pgtypeUUID(r.GetNamespaceId()), + NamespaceFqn: pgtypeText(r.GetNamespaceFqn()), + Limit: limit, + Offset: offset, }) if err != nil { return nil, db.WrapIfKnownInvalidQueryErr(err) @@ -89,9 +91,13 @@ func (c PolicyDBClient) GetResourceMappingGroup(ctx context.Context, id string) } func (c PolicyDBClient) CreateResourceMappingGroup(ctx context.Context, r *resourcemapping.CreateResourceMappingGroupRequest) (*policy.ResourceMappingGroup, error) { - namespaceID := r.GetNamespaceId() name := strings.ToLower(r.GetName()) + namespaceID, err := c.resolveNamespaceID(ctx, r.GetNamespaceId(), r.GetNamespaceFqn()) + if err != nil { + return nil, err + } + metadataJSON, _, err := db.MarshalCreateMetadata(r.GetMetadata()) if err != nil { return nil, err @@ -110,9 +116,13 @@ func (c PolicyDBClient) CreateResourceMappingGroup(ctx context.Context, r *resou } func (c PolicyDBClient) UpdateResourceMappingGroup(ctx context.Context, id string, r *resourcemapping.UpdateResourceMappingGroupRequest) (*policy.ResourceMappingGroup, error) { - namespaceID := r.GetNamespaceId() name := strings.ToLower(r.GetName()) + namespaceID, err := c.resolveNamespaceID(ctx, r.GetNamespaceId(), r.GetNamespaceFqn()) + if err != nil { + return nil, err + } + metadataJSON, _, err := db.MarshalUpdateMetadata(r.GetMetadata(), r.GetMetadataUpdateBehavior(), func() (*common.Metadata, error) { rmGroup, err := c.GetResourceMappingGroup(ctx, id) if err != nil { @@ -170,9 +180,11 @@ func (c PolicyDBClient) ListResourceMappings(ctx context.Context, r *resourcemap } list, err := c.queries.listResourceMappings(ctx, listResourceMappingsParams{ - GroupID: pgtypeUUID(r.GetGroupId()), - Limit: limit, - Offset: offset, + GroupID: pgtypeUUID(r.GetGroupId()), + NamespaceID: pgtypeUUID(r.GetNamespaceId()), + NamespaceFqn: pgtypeText(r.GetNamespaceFqn()), + Limit: limit, + Offset: offset, }) if err != nil { return nil, db.WrapIfKnownInvalidQueryErr(err) @@ -202,12 +214,21 @@ func (c PolicyDBClient) ListResourceMappings(ctx context.Context, r *resourcemap } } + var namespace *policy.Namespace + if rm.Namespace != nil { + namespace = new(policy.Namespace) + if err = unmarshalNamespace(rm.Namespace, namespace); err != nil { + return nil, err + } + } + mapping := &policy.ResourceMapping{ Id: rm.ID, AttributeValue: attributeValue, Terms: rm.Terms, Metadata: metadata, Group: resourceMappingGroup, + Namespace: namespace, } mappings[i] = mapping } @@ -268,11 +289,20 @@ func (c PolicyDBClient) ListResourceMappingsByGroupFqns(ctx context.Context, fqn return nil, err } + var namespace *policy.Namespace + if row.Namespace != nil { + namespace = new(policy.Namespace) + if err := unmarshalNamespace(row.Namespace, namespace); err != nil { + return nil, err + } + } + mappings[i] = &policy.ResourceMapping{ Id: row.ID, AttributeValue: value, Terms: row.Terms, Metadata: metadata, + Namespace: namespace, } } @@ -325,17 +355,77 @@ func (c PolicyDBClient) GetResourceMapping(ctx context.Context, id string) (*pol } } + var namespace *policy.Namespace + if rm.Namespace != nil { + namespace = new(policy.Namespace) + if err = unmarshalNamespace(rm.Namespace, namespace); err != nil { + return nil, err + } + } + policyRM := &policy.ResourceMapping{ Id: rm.ID, AttributeValue: attributeValue, Terms: rm.Terms, Metadata: metadata, Group: resourceMappingGroup, + Namespace: namespace, } return policyRM, nil } +// resolveResourceMappingNamespaceID determines the owning namespace UUID for a +// resource mapping from the optional namespace_id / namespace_fqn fields and the +// optional group it belongs to. A mapping that belongs to a group must share the +// group's namespace, so any explicitly provided namespace must match it (and may +// be omitted to inherit it). The mapped attribute value's namespace is +// independent and intentionally not constrained here, allowing mappings to cross +// namespaces to the attribute values they map. Returns an empty string when the +// mapping has no owning namespace (legacy/global). +// resolveNamespaceID resolves an optional namespace reference provided as either +// a UUID or an FQN to its namespace UUID. Returns an empty string when neither +// is provided. +func (c PolicyDBClient) resolveNamespaceID(ctx context.Context, namespaceID, namespaceFqn string) (string, error) { + switch { + case namespaceID != "": + if !pgtypeUUID(namespaceID).Valid { + return "", db.ErrUUIDInvalid + } + return namespaceID, nil + case namespaceFqn != "": + ns, err := c.GetNamespace(ctx, &namespaces.GetNamespaceRequest_Fqn{Fqn: namespaceFqn}) + if err != nil { + return "", err + } + return ns.GetId(), nil + } + return "", nil +} + +func (c PolicyDBClient) resolveResourceMappingNamespaceID(ctx context.Context, namespaceID, namespaceFqn, groupID string) (string, error) { + // Resolve any explicitly provided namespace to its UUID. + namespaceID, err := c.resolveNamespaceID(ctx, namespaceID, namespaceFqn) + if err != nil { + return "", err + } + + // If the mapping belongs to a group, it must live in the group's namespace. + if groupID != "" { + group, err := c.GetResourceMappingGroup(ctx, groupID) + if err != nil { + return "", db.WrapIfKnownInvalidQueryErr(err) + } + groupNamespaceID := group.GetNamespaceId() + if namespaceID != "" && namespaceID != groupNamespaceID { + return "", db.ErrNamespaceMismatch + } + namespaceID = groupNamespaceID + } + + return namespaceID, nil +} + func (c PolicyDBClient) CreateResourceMapping(ctx context.Context, r *resourcemapping.CreateResourceMappingRequest) (*policy.ResourceMapping, error) { attributeValueID := r.GetAttributeValueId() terms := r.GetTerms() @@ -345,23 +435,9 @@ func (c PolicyDBClient) CreateResourceMapping(ctx context.Context, r *resourcema return nil, err } - if groupID != "" { - // get the attribute value and resource mapping group, ensure the namesapce is the same - attrVal, err := c.GetAttributeValue(ctx, attributeValueID) - if err != nil { - return nil, db.WrapIfKnownInvalidQueryErr(err) - } - attr, err := c.GetAttribute(ctx, attrVal.GetAttribute().GetId()) - if err != nil { - return nil, db.WrapIfKnownInvalidQueryErr(err) - } - group, err := c.GetResourceMappingGroup(ctx, groupID) - if err != nil { - return nil, db.WrapIfKnownInvalidQueryErr(err) - } - if attr.GetNamespace().GetId() != group.GetNamespaceId() { - return nil, db.ErrNamespaceMismatch - } + namespaceID, err := c.resolveResourceMappingNamespaceID(ctx, r.GetNamespaceId(), r.GetNamespaceFqn(), groupID) + if err != nil { + return nil, err } createdID, err := c.queries.createResourceMapping(ctx, createResourceMappingParams{ @@ -369,6 +445,7 @@ func (c PolicyDBClient) CreateResourceMapping(ctx context.Context, r *resourcema Terms: terms, Metadata: metadataJSON, GroupID: pgtypeUUID(groupID), + NamespaceID: pgtypeUUID(namespaceID), }) if err != nil { return nil, db.WrapIfKnownInvalidQueryErr(err) @@ -392,23 +469,21 @@ func (c PolicyDBClient) UpdateResourceMapping(ctx context.Context, id string, r return nil, err } - if groupID != "" { - // get the attribute value and resource mapping group, ensure the namesapce is the same - attrVal, err := c.GetAttributeValue(ctx, attributeValueID) - if err != nil { - return nil, db.WrapIfKnownInvalidQueryErr(err) - } - attr, err := c.GetAttribute(ctx, attrVal.GetAttribute().GetId()) - if err != nil { - return nil, db.WrapIfKnownInvalidQueryErr(err) - } - group, err := c.GetResourceMappingGroup(ctx, groupID) + // Determine the group that governs namespace consistency: the group set in + // this request, or (when only a namespace is being changed) the mapping's + // existing group. + effectiveGroupID := groupID + if effectiveGroupID == "" && (r.GetNamespaceId() != "" || r.GetNamespaceFqn() != "") { + existing, err := c.GetResourceMapping(ctx, id) if err != nil { - return nil, db.WrapIfKnownInvalidQueryErr(err) - } - if attr.GetNamespace().GetId() != group.GetNamespaceId() { - return nil, db.ErrNamespaceMismatch + return nil, err } + effectiveGroupID = existing.GetGroup().GetId() + } + + namespaceID, err := c.resolveResourceMappingNamespaceID(ctx, r.GetNamespaceId(), r.GetNamespaceFqn(), effectiveGroupID) + if err != nil { + return nil, err } count, err := c.queries.updateResourceMapping(ctx, updateResourceMappingParams{ @@ -417,6 +492,7 @@ func (c PolicyDBClient) UpdateResourceMapping(ctx context.Context, id string, r Terms: terms, Metadata: metadataJSON, GroupID: pgtypeUUID(groupID), + NamespaceID: pgtypeUUID(namespaceID), }) if err != nil { return nil, db.WrapIfKnownInvalidQueryErr(err) diff --git a/service/policy/db/resource_mapping.sql.go b/service/policy/db/resource_mapping.sql.go index 06313547d9..895f5018fc 100644 --- a/service/policy/db/resource_mapping.sql.go +++ b/service/policy/db/resource_mapping.sql.go @@ -12,8 +12,8 @@ import ( ) const createResourceMapping = `-- name: createResourceMapping :one -INSERT INTO resource_mappings (attribute_value_id, terms, metadata, group_id) -VALUES ($1, $2, $3, $4) +INSERT INTO resource_mappings (attribute_value_id, terms, metadata, group_id, namespace_id) +VALUES ($1, $2, $3, $4, $5) RETURNING id ` @@ -22,12 +22,13 @@ type createResourceMappingParams struct { Terms []string `json:"terms"` Metadata []byte `json:"metadata"` GroupID pgtype.UUID `json:"group_id"` + NamespaceID pgtype.UUID `json:"namespace_id"` } // createResourceMapping // -// INSERT INTO resource_mappings (attribute_value_id, terms, metadata, group_id) -// VALUES ($1, $2, $3, $4) +// INSERT INTO resource_mappings (attribute_value_id, terms, metadata, group_id, namespace_id) +// VALUES ($1, $2, $3, $4, $5) // RETURNING id func (q *Queries) createResourceMapping(ctx context.Context, arg createResourceMappingParams) (string, error) { row := q.db.QueryRow(ctx, createResourceMapping, @@ -35,6 +36,7 @@ func (q *Queries) createResourceMapping(ctx context.Context, arg createResourceM arg.Terms, arg.Metadata, arg.GroupID, + arg.NamespaceID, ) var id string err := row.Scan(&id) @@ -109,14 +111,24 @@ SELECT 'namespace_id', rmg.namespace_id, 'fqn', CONCAT('https://', rmg_ns.name, '/resm/', rmg.name)::TEXT ) - END)::JSON AS group -FROM resource_mappings m + END)::JSON AS group, + (CASE + WHEN m.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT( + 'id', rm_ns.id, + 'name', rm_ns.name, + 'fqn', rm_ns_fqn.fqn + ) + END)::JSON AS namespace +FROM resource_mappings m LEFT JOIN attribute_values av on m.attribute_value_id = av.id LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id LEFT JOIN resource_mapping_groups rmg ON m.group_id = rmg.id LEFT JOIN attribute_namespaces rmg_ns ON rmg.namespace_id = rmg_ns.id +LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL WHERE m.id = $1 -GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name +GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name, rm_ns.id, rm_ns_fqn.fqn ` type getResourceMappingRow struct { @@ -125,6 +137,7 @@ type getResourceMappingRow struct { Terms []string `json:"terms"` Metadata []byte `json:"metadata"` Group []byte `json:"group"` + Namespace []byte `json:"namespace"` } // getResourceMapping @@ -142,14 +155,24 @@ type getResourceMappingRow struct { // 'namespace_id', rmg.namespace_id, // 'fqn', CONCAT('https://', rmg_ns.name, '/resm/', rmg.name)::TEXT // ) -// END)::JSON AS group +// END)::JSON AS group, +// (CASE +// WHEN m.namespace_id IS NULL THEN NULL +// ELSE JSON_BUILD_OBJECT( +// 'id', rm_ns.id, +// 'name', rm_ns.name, +// 'fqn', rm_ns_fqn.fqn +// ) +// END)::JSON AS namespace // FROM resource_mappings m // LEFT JOIN attribute_values av on m.attribute_value_id = av.id // LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id // LEFT JOIN resource_mapping_groups rmg ON m.group_id = rmg.id // LEFT JOIN attribute_namespaces rmg_ns ON rmg.namespace_id = rmg_ns.id +// LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +// LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL // WHERE m.id = $1 -// GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name +// GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name, rm_ns.id, rm_ns_fqn.fqn func (q *Queries) getResourceMapping(ctx context.Context, id string) (getResourceMappingRow, error) { row := q.db.QueryRow(ctx, getResourceMapping, id) var i getResourceMappingRow @@ -159,6 +182,7 @@ func (q *Queries) getResourceMapping(ctx context.Context, id string) (getResourc &i.Terms, &i.Metadata, &i.Group, + &i.Namespace, ) return i, err } @@ -215,16 +239,19 @@ SELECT rmg.id, COUNT(*) OVER() AS total FROM resource_mapping_groups rmg JOIN attribute_namespaces ns ON rmg.namespace_id = ns.id -WHERE ($1::uuid IS NULL OR rmg.namespace_id = $1::uuid) +LEFT JOIN attribute_fqns ns_fqn ON ns_fqn.namespace_id = ns.id AND ns_fqn.attribute_id IS NULL AND ns_fqn.value_id IS NULL +WHERE ($1::uuid IS NULL OR rmg.namespace_id = $1::uuid) + AND ($2::text IS NULL OR ns_fqn.fqn = $2::text) ORDER BY rmg.created_at DESC -LIMIT $3 -OFFSET $2 +LIMIT $4 +OFFSET $3 ` type listResourceMappingGroupsParams struct { - NamespaceID pgtype.UUID `json:"namespace_id"` - Offset int32 `json:"offset_"` - Limit int32 `json:"limit_"` + NamespaceID pgtype.UUID `json:"namespace_id"` + NamespaceFqn pgtype.Text `json:"namespace_fqn"` + Offset int32 `json:"offset_"` + Limit int32 `json:"limit_"` } type listResourceMappingGroupsRow struct { @@ -248,12 +275,19 @@ type listResourceMappingGroupsRow struct { // COUNT(*) OVER() AS total // FROM resource_mapping_groups rmg // JOIN attribute_namespaces ns ON rmg.namespace_id = ns.id +// LEFT JOIN attribute_fqns ns_fqn ON ns_fqn.namespace_id = ns.id AND ns_fqn.attribute_id IS NULL AND ns_fqn.value_id IS NULL // WHERE ($1::uuid IS NULL OR rmg.namespace_id = $1::uuid) +// AND ($2::text IS NULL OR ns_fqn.fqn = $2::text) // ORDER BY rmg.created_at DESC -// LIMIT $3 -// OFFSET $2 +// LIMIT $4 +// OFFSET $3 func (q *Queries) listResourceMappingGroups(ctx context.Context, arg listResourceMappingGroupsParams) ([]listResourceMappingGroupsRow, error) { - rows, err := q.db.Query(ctx, listResourceMappingGroups, arg.NamespaceID, arg.Offset, arg.Limit) + rows, err := q.db.Query(ctx, listResourceMappingGroups, + arg.NamespaceID, + arg.NamespaceFqn, + arg.Offset, + arg.Limit, + ) if err != nil { return nil, err } @@ -295,23 +329,37 @@ SELECT 'fqn', CONCAT('https://', rmg_ns.name, '/resm/', rmg.name)::TEXT ) END)::JSON AS group, + (CASE + WHEN m.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT( + 'id', rm_ns.id, + 'name', rm_ns.name, + 'fqn', rm_ns_fqn.fqn + ) + END)::JSON AS namespace, COUNT(*) OVER() AS total FROM resource_mappings m LEFT JOIN attribute_values av on m.attribute_value_id = av.id LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id LEFT JOIN resource_mapping_groups rmg ON m.group_id = rmg.id LEFT JOIN attribute_namespaces rmg_ns ON rmg.namespace_id = rmg_ns.id +LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL WHERE ($1::uuid IS NULL OR m.group_id = $1::uuid) -GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name + AND ($2::uuid IS NULL OR m.namespace_id = $2::uuid) + AND ($3::text IS NULL OR rm_ns_fqn.fqn = $3::text) +GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name, rm_ns.id, rm_ns_fqn.fqn ORDER BY m.created_at DESC -LIMIT $3 -OFFSET $2 +LIMIT $5 +OFFSET $4 ` type listResourceMappingsParams struct { - GroupID pgtype.UUID `json:"group_id"` - Offset int32 `json:"offset_"` - Limit int32 `json:"limit_"` + GroupID pgtype.UUID `json:"group_id"` + NamespaceID pgtype.UUID `json:"namespace_id"` + NamespaceFqn pgtype.Text `json:"namespace_fqn"` + Offset int32 `json:"offset_"` + Limit int32 `json:"limit_"` } type listResourceMappingsRow struct { @@ -320,6 +368,7 @@ type listResourceMappingsRow struct { Terms []string `json:"terms"` Metadata []byte `json:"metadata"` Group []byte `json:"group"` + Namespace []byte `json:"namespace"` Total int64 `json:"total"` } @@ -341,19 +390,37 @@ type listResourceMappingsRow struct { // 'fqn', CONCAT('https://', rmg_ns.name, '/resm/', rmg.name)::TEXT // ) // END)::JSON AS group, +// (CASE +// WHEN m.namespace_id IS NULL THEN NULL +// ELSE JSON_BUILD_OBJECT( +// 'id', rm_ns.id, +// 'name', rm_ns.name, +// 'fqn', rm_ns_fqn.fqn +// ) +// END)::JSON AS namespace, // COUNT(*) OVER() AS total // FROM resource_mappings m // LEFT JOIN attribute_values av on m.attribute_value_id = av.id // LEFT JOIN attribute_fqns fqns on av.id = fqns.value_id // LEFT JOIN resource_mapping_groups rmg ON m.group_id = rmg.id // LEFT JOIN attribute_namespaces rmg_ns ON rmg.namespace_id = rmg_ns.id +// LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +// LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL // WHERE ($1::uuid IS NULL OR m.group_id = $1::uuid) -// GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name +// AND ($2::uuid IS NULL OR m.namespace_id = $2::uuid) +// AND ($3::text IS NULL OR rm_ns_fqn.fqn = $3::text) +// GROUP BY av.id, m.id, fqns.fqn, rmg.id, rmg.name, rmg.namespace_id, rmg_ns.name, rm_ns.id, rm_ns_fqn.fqn // ORDER BY m.created_at DESC -// LIMIT $3 -// OFFSET $2 +// LIMIT $5 +// OFFSET $4 func (q *Queries) listResourceMappings(ctx context.Context, arg listResourceMappingsParams) ([]listResourceMappingsRow, error) { - rows, err := q.db.Query(ctx, listResourceMappings, arg.GroupID, arg.Offset, arg.Limit) + rows, err := q.db.Query(ctx, listResourceMappings, + arg.GroupID, + arg.NamespaceID, + arg.NamespaceFqn, + arg.Offset, + arg.Limit, + ) if err != nil { return nil, err } @@ -367,6 +434,7 @@ func (q *Queries) listResourceMappings(ctx context.Context, arg listResourceMapp &i.Terms, &i.Metadata, &i.Group, + &i.Namespace, &i.Total, ); err != nil { return nil, err @@ -403,11 +471,21 @@ SELECT JSON_BUILD_OBJECT('id', av.id, 'value', av.value, 'fqn', fqns.fqn) as attribute_value, m.terms, JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', m.metadata -> 'labels', 'created_at', m.created_at, 'updated_at', m.updated_at)) as metadata, - g.group + g.group, + (CASE + WHEN m.namespace_id IS NULL THEN NULL + ELSE JSON_BUILD_OBJECT( + 'id', rm_ns.id, + 'name', rm_ns.name, + 'fqn', rm_ns_fqn.fqn + ) + END)::JSON AS namespace FROM resource_mappings m JOIN groups_cte g ON m.group_id = g.id JOIN attribute_values av on m.attribute_value_id = av.id JOIN attribute_fqns fqns on av.id = fqns.value_id +LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL ORDER BY m.created_at DESC ` @@ -422,6 +500,7 @@ type listResourceMappingsByFullyQualifiedGroupRow struct { Terms []string `json:"terms"` Metadata []byte `json:"metadata"` Group []byte `json:"group"` + Namespace []byte `json:"namespace"` } // CTE to cache the group JSON build since it will be the same for all mappings of the group @@ -449,11 +528,21 @@ type listResourceMappingsByFullyQualifiedGroupRow struct { // JSON_BUILD_OBJECT('id', av.id, 'value', av.value, 'fqn', fqns.fqn) as attribute_value, // m.terms, // JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', m.metadata -> 'labels', 'created_at', m.created_at, 'updated_at', m.updated_at)) as metadata, -// g.group +// g.group, +// (CASE +// WHEN m.namespace_id IS NULL THEN NULL +// ELSE JSON_BUILD_OBJECT( +// 'id', rm_ns.id, +// 'name', rm_ns.name, +// 'fqn', rm_ns_fqn.fqn +// ) +// END)::JSON AS namespace // FROM resource_mappings m // JOIN groups_cte g ON m.group_id = g.id // JOIN attribute_values av on m.attribute_value_id = av.id // JOIN attribute_fqns fqns on av.id = fqns.value_id +// LEFT JOIN attribute_namespaces rm_ns ON m.namespace_id = rm_ns.id +// LEFT JOIN attribute_fqns rm_ns_fqn ON rm_ns_fqn.namespace_id = rm_ns.id AND rm_ns_fqn.attribute_id IS NULL AND rm_ns_fqn.value_id IS NULL // ORDER BY m.created_at DESC func (q *Queries) listResourceMappingsByFullyQualifiedGroup(ctx context.Context, arg listResourceMappingsByFullyQualifiedGroupParams) ([]listResourceMappingsByFullyQualifiedGroupRow, error) { rows, err := q.db.Query(ctx, listResourceMappingsByFullyQualifiedGroup, arg.NamespaceName, arg.GroupName) @@ -470,6 +559,7 @@ func (q *Queries) listResourceMappingsByFullyQualifiedGroup(ctx context.Context, &i.Terms, &i.Metadata, &i.Group, + &i.Namespace, ); err != nil { return nil, err } @@ -487,7 +577,8 @@ SET attribute_value_id = COALESCE($2, attribute_value_id), terms = COALESCE($3, terms), metadata = COALESCE($4, metadata), - group_id = COALESCE($5, group_id) + group_id = COALESCE($5, group_id), + namespace_id = COALESCE($6, namespace_id) WHERE id = $1 ` @@ -497,6 +588,7 @@ type updateResourceMappingParams struct { Terms []string `json:"terms"` Metadata []byte `json:"metadata"` GroupID pgtype.UUID `json:"group_id"` + NamespaceID pgtype.UUID `json:"namespace_id"` } // updateResourceMapping @@ -506,7 +598,8 @@ type updateResourceMappingParams struct { // attribute_value_id = COALESCE($2, attribute_value_id), // terms = COALESCE($3, terms), // metadata = COALESCE($4, metadata), -// group_id = COALESCE($5, group_id) +// group_id = COALESCE($5, group_id), +// namespace_id = COALESCE($6, namespace_id) // WHERE id = $1 func (q *Queries) updateResourceMapping(ctx context.Context, arg updateResourceMappingParams) (int64, error) { result, err := q.db.Exec(ctx, updateResourceMapping, @@ -515,6 +608,7 @@ func (q *Queries) updateResourceMapping(ctx context.Context, arg updateResourceM arg.Terms, arg.Metadata, arg.GroupID, + arg.NamespaceID, ) if err != nil { return 0, err diff --git a/service/policy/resourcemapping/resource_mapping.go b/service/policy/resourcemapping/resource_mapping.go index af87df416f..41d7334ca6 100644 --- a/service/policy/resourcemapping/resource_mapping.go +++ b/service/policy/resourcemapping/resource_mapping.go @@ -2,6 +2,7 @@ package resourcemapping import ( "context" + "errors" "fmt" "log/slog" @@ -258,6 +259,13 @@ func (s ResourceMappingService) CreateResourceMapping(ctx context.Context, s.logger.DebugContext(ctx, "creating resource mapping") + // --- BEGIN namespace enforcement (remove when namespaced_policy flag is phased out) --- + // A group implies a namespace, so a mapping assigned to a group satisfies the requirement. + if s.config.NamespacedPolicy && req.Msg.GetNamespaceId() == "" && req.Msg.GetNamespaceFqn() == "" && req.Msg.GetGroupId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("namespace is required: provide either namespace_id, namespace_fqn, or group_id")) + } + // --- END namespace enforcement --- + auditParams := audit.PolicyEventParams{ ActionType: audit.ActionTypeCreate, ObjectType: audit.ObjectTypeResourceMapping,