diff --git a/service/integration/resource_mappings_test.go b/service/integration/resource_mappings_test.go index b11c5b48b1..4906cd92ed 100644 --- a/service/integration/resource_mappings_test.go +++ b/service/integration/resource_mappings_test.go @@ -639,23 +639,193 @@ 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_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() { @@ -1421,27 +1591,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 +1653,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..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,