From a725a6142e8b43256a90429cfc622a4c859d55a1 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Thu, 4 Jun 2026 15:16:37 -0400 Subject: [PATCH 1/2] feat(policy): DSPX-2998 optionally namespace resource mappings Implement the service side of optional namespacing for resource mappings: - Add a migration adding a nullable namespace_id (FK to attribute_namespaces, ON DELETE CASCADE) plus an index to resource_mappings. - Resolve a mapping's owning namespace from namespace_id/namespace_fqn or its group; a grouped mapping must share the group's namespace. - Remove the constraint forcing the mapped attribute value into the group's namespace, allowing mappings to cross namespaces to the values they map. - Hydrate the namespace on get/list responses and add namespace_id/namespace_fqn filters to ListResourceMappings, plus namespace_fqn to ListResourceMappingGroups. - Enforce a namespace on create when namespaced_policy is enabled (namespace_id, namespace_fqn, or group_id satisfies it). - Update/extend integration tests for the new behavior. Stacked on the proto PR (#3565). Signed-off-by: Krish Suchak --- service/integration/resource_mappings_test.go | 209 ++++++++++++++++-- ...000_add_namespace_to_resource_mappings.sql | 26 +++ service/policy/db/models.go | 2 + .../policy/db/queries/resource_mapping.sql | 61 +++-- service/policy/db/resource_mapping.go | 132 +++++++---- service/policy/db/resource_mapping.sql.go | 158 ++++++++++--- .../resourcemapping/resource_mapping.go | 8 + 7 files changed, 491 insertions(+), 105 deletions(-) create mode 100644 service/policy/db/migrations/20260604000000_add_namespace_to_resource_mappings.sql diff --git a/service/integration/resource_mappings_test.go b/service/integration/resource_mappings_test.go index b11c5b48b1..0c2e0357cc 100644 --- a/service/integration/resource_mappings_test.go +++ b/service/integration/resource_mappings_test.go @@ -639,23 +639,155 @@ 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_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() { + ns, cleanup := s.createIsolatedNamespace("rm-list-ns-id") + defer cleanup() + attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1") + + created, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{ + AttributeValueId: attrValue.ID, + Terms: []string{"list-ns-id-term"}, + NamespaceId: ns.GetId(), + }) + s.Require().NoError(err) + + listRsp, err := s.db.PolicyClient.ListResourceMappings(s.ctx, &resourcemapping.ListResourceMappingsRequest{ + NamespaceId: ns.GetId(), + }) + s.Require().NoError(err) + s.Require().NotNil(listRsp) + + s.Len(listRsp.GetResourceMappings(), 1, "isolated namespace should own exactly one mapping") + s.Equal(created.GetId(), listRsp.GetResourceMappings()[0].GetId()) + s.Equal(ns.GetId(), listRsp.GetResourceMappings()[0].GetNamespace().GetId()) +} + +func (s *ResourceMappingsSuite) Test_ListResourceMappings_FilterByNamespaceFqn_Succeeds() { + ns, cleanup := s.createIsolatedNamespace("rm-list-ns-fqn") + defer cleanup() + attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1") + + created, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{ + AttributeValueId: attrValue.ID, + Terms: []string{"list-ns-fqn-term"}, + NamespaceFqn: ns.GetFqn(), + }) + s.Require().NoError(err) + + listRsp, err := s.db.PolicyClient.ListResourceMappings(s.ctx, &resourcemapping.ListResourceMappingsRequest{ + NamespaceFqn: ns.GetFqn(), + }) + s.Require().NoError(err) + s.Require().NotNil(listRsp) + + s.Len(listRsp.GetResourceMappings(), 1, "isolated namespace should own exactly one mapping") + s.Equal(created.GetId(), listRsp.GetResourceMappings()[0].GetId()) + s.Equal(ns.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 +1553,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 +1615,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/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..fd12ce85c8 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) @@ -170,9 +172,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 +206,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 +281,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 +347,65 @@ 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). +func (c PolicyDBClient) resolveResourceMappingNamespaceID(ctx context.Context, namespaceID, namespaceFqn, groupID string) (string, error) { + // Resolve any explicitly provided namespace to its UUID. + switch { + case namespaceID != "": + if !pgtypeUUID(namespaceID).Valid { + return "", db.ErrUUIDInvalid + } + case namespaceFqn != "": + ns, err := c.GetNamespace(ctx, &namespaces.GetNamespaceRequest_Fqn{Fqn: namespaceFqn}) + if err != nil { + return "", err + } + namespaceID = ns.GetId() + } + + // 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 +415,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 +425,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 +449,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()) + // 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) - } - group, err := c.GetResourceMappingGroup(ctx, groupID) - 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 +472,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, From 6f5d97958a64dda9a66339e80eb0d2bfbec921d6 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Fri, 5 Jun 2026 13:12:15 -0400 Subject: [PATCH 2/2] feat(policy): DSPX-2998 backfill resource mapping namespaces from groups Add a migration that backfills resource_mappings.namespace_id from the owning group for existing grouped mappings created before the column existed, so namespace filtering works on legacy data. Ungrouped mappings remain global. Idempotent (only fills NULL rows). Adds an integration test for the backfill. Signed-off-by: Krish Suchak --- service/integration/resource_mappings_test.go | 38 +++++++++++++++++++ ...00_backfill_resource_mapping_namespace.sql | 25 ++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 service/policy/db/migrations/20260605000000_backfill_resource_mapping_namespace.sql diff --git a/service/integration/resource_mappings_test.go b/service/integration/resource_mappings_test.go index 0c2e0357cc..4906cd92ed 100644 --- a/service/integration/resource_mappings_test.go +++ b/service/integration/resource_mappings_test.go @@ -788,6 +788,44 @@ func (s *ResourceMappingsSuite) Test_ListResourceMappingGroups_WithNamespaceFqn_ s.Equal(ns.GetId(), list[0].GetNamespaceId()) } +func (s *ResourceMappingsSuite) Test_BackfillResourceMappingNamespace_FromGroup() { + // Exercises the migration-time backfill logic: a grouped mapping whose + // namespace_id was never set (legacy data) is backfilled from its group. + ns, group, cleanup := s.createIsolatedNamespaceAndGroup("rm-backfill") + defer cleanup() + attrValue := s.f.GetAttributeValueKey("example.com/attr/attr1/value/value1") + + created, err := s.db.PolicyClient.CreateResourceMapping(s.ctx, &resourcemapping.CreateResourceMappingRequest{ + AttributeValueId: attrValue.ID, + Terms: []string{"backfill-term"}, + GroupId: group.GetId(), + }) + s.Require().NoError(err) + s.Require().Equal(ns.GetId(), created.GetNamespace().GetId()) + + rmTable := s.db.TableName("resource_mappings") + rmgTable := s.db.TableName("resource_mapping_groups") + + // Simulate legacy data created before namespace_id existed. + _, err = s.db.Client.Pgx.Exec(s.ctx, "UPDATE "+rmTable+" SET namespace_id = NULL WHERE id = $1", created.GetId()) + s.Require().NoError(err) + + cleared, err := s.db.PolicyClient.GetResourceMapping(s.ctx, created.GetId()) + s.Require().NoError(err) + s.Nil(cleared.GetNamespace(), "namespace should be cleared to simulate legacy data") + + // Run the same backfill the migration performs, scoped to this mapping so the + // test does not mutate shared fixture rows (and their updated_at triggers). + _, err = s.db.Client.Pgx.Exec(s.ctx, + "UPDATE "+rmTable+" m SET namespace_id = g.namespace_id FROM "+rmgTable+" g WHERE m.group_id = g.id AND m.namespace_id IS NULL AND m.id = $1", + created.GetId()) + s.Require().NoError(err) + + backfilled, err := s.db.PolicyClient.GetResourceMapping(s.ctx, created.GetId()) + s.Require().NoError(err) + s.Equal(ns.GetId(), backfilled.GetNamespace().GetId(), "grouped mapping should be backfilled with the group's namespace") +} + func (s *ResourceMappingsSuite) Test_ListResourceMappings_NoPagination_Succeeds() { testMappings := make(map[string]fixtures.FixtureDataResourceMapping) for _, testMapping := range s.getResourceMappingFixtures() { 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