diff --git a/service/integration/registered_resources_test.go b/service/integration/registered_resources_test.go new file mode 100644 index 0000000000..5b7a0ffc4b --- /dev/null +++ b/service/integration/registered_resources_test.go @@ -0,0 +1,917 @@ +package integration + +import ( + "context" + "log/slog" + "strings" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/service/internal/fixtures" + "github.com/opentdf/platform/service/pkg/db" + "github.com/stretchr/testify/suite" + "google.golang.org/protobuf/proto" +) + +type RegisteredResourcesSuite struct { + suite.Suite + f fixtures.Fixtures + db fixtures.DBInterface + ctx context.Context //nolint:containedctx // context is used in the test suite +} + +func (s *RegisteredResourcesSuite) SetupSuite() { + slog.Info("setting up db.RegisteredResources test suite") + s.ctx = context.Background() + c := *Config + c.DB.Schema = "test_opentdf_registered_resources" + s.db = fixtures.NewDBInterface(c) + s.f = fixtures.NewFixture(s.db) + s.f.Provision() +} + +func (s *RegisteredResourcesSuite) TearDownSuite() { + slog.Info("tearing down db.RegisteredResources test suite") + s.f.TearDown() +} + +func TestRegisteredResourcesSuite(t *testing.T) { + if testing.Short() { + t.Skip("skipping registered resources integration test") + } + suite.Run(t, new(RegisteredResourcesSuite)) +} + +const invalidID = "00000000-0000-0000-0000-000000000000" + +/// +/// Registered Resources +/// + +// Create + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResource_Succeeds() { + req := ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_create_res", + } + + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, req) + s.Require().NoError(err) + s.NotNil(created) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResource_NormalizedName_Succeeds() { + req := ®isteredresources.CreateRegisteredResourceRequest{ + Name: "TeST_CrEaTe_RES_NorMa-LiZeD", + } + + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, req) + s.Require().NoError(err) + s.NotNil(created) + s.Equal(strings.ToLower(req.GetName()), created.GetName()) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResource_WithValues_Succeeds() { + values := []string{ + "test_create_res_values__value1", + "test_create_res_values__value2", + } + req := ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_create_res_values", + Values: values, + } + + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, req) + s.Require().NoError(err) + s.NotNil(created) + createdVals := created.GetValues() + s.Require().Len(createdVals, 2) + s.Equal(values[0], createdVals[0].GetValue()) + s.Equal(values[1], createdVals[1].GetValue()) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResource_WithMetadata_Succeeds() { + req := ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_create_res_metadata", + Metadata: &common.MetadataMutable{ + Labels: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + } + + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, req) + s.Require().NoError(err) + s.NotNil(created) + s.Require().Len(created.GetMetadata().GetLabels(), 2) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResource_WithNonUniqueName_Fails() { + existing := s.f.GetRegisteredResourceKey("res_with_values") + req := ®isteredresources.CreateRegisteredResourceRequest{ + Name: existing.Name, + } + + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, req) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrUniqueConstraintViolation) + s.Nil(created) +} + +// Get + +func (s *RegisteredResourcesSuite) Test_GetRegisteredResource_Succeeds() { + existingRes := s.f.GetRegisteredResourceKey("res_only") + + got, err := s.db.PolicyClient.GetRegisteredResource(s.ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: existingRes.ID, + }, + }) + s.Require().NoError(err) + s.NotNil(got) + s.Equal(existingRes.Name, got.GetName()) + metadata := got.GetMetadata() + s.False(metadata.GetCreatedAt().AsTime().IsZero()) + s.False(metadata.GetUpdatedAt().AsTime().IsZero()) + s.Empty(got.GetValues()) +} + +func (s *RegisteredResourcesSuite) Test_GetRegisteredResource_WithValues_Succeeds() { + existingRes := s.f.GetRegisteredResourceKey("res_with_values") + existingResValue1 := s.f.GetRegisteredResourceValueKey("res_with_values__value1") + + got, err := s.db.PolicyClient.GetRegisteredResource(s.ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: existingRes.ID, + }, + }) + s.Require().NoError(err) + s.NotNil(got) + values := got.GetValues() + s.Require().Len(values, 2) + var found bool + for _, v := range values { + // check at least one of the expected values exists + if existingResValue1.ID == v.GetId() { + found = true + s.Equal(existingResValue1.Value, v.GetValue()) + break + } + } + s.True(found) +} + +func (s *RegisteredResourcesSuite) Test_GetRegisteredResource_InvalidID_Fails() { + got, err := s.db.PolicyClient.GetRegisteredResource(s.ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: invalidID, + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(got) +} + +// List + +func (s *RegisteredResourcesSuite) Test_ListRegisteredResources_NoPagination_Succeeds() { + existingRes := s.f.GetRegisteredResourceKey("res_with_values") + existingResOnly := s.f.GetRegisteredResourceKey("res_only") + + list, err := s.db.PolicyClient.ListRegisteredResources(s.ctx, ®isteredresources.ListRegisteredResourcesRequest{}) + s.Require().NoError(err) + s.NotNil(list) + + foundCount := 0 + + for _, r := range list.GetResources() { + if r.GetId() == existingRes.ID { + foundCount++ + s.Equal(existingRes.Name, r.GetName()) + values := r.GetValues() + s.Require().Len(values, 2) + metadata := r.GetMetadata() + s.False(metadata.GetCreatedAt().AsTime().IsZero()) + s.False(metadata.GetUpdatedAt().AsTime().IsZero()) + } + + if r.GetId() == existingResOnly.ID { + foundCount++ + s.Equal(existingResOnly.Name, r.GetName()) + s.Require().Empty(r.GetValues()) + metadata := r.GetMetadata() + s.False(metadata.GetCreatedAt().AsTime().IsZero()) + s.False(metadata.GetUpdatedAt().AsTime().IsZero()) + } + } + + s.Equal(2, foundCount) +} + +func (s *RegisteredResourcesSuite) Test_ListRegisteredResources_Limit_Succeeds() { + var limit int32 = 1 + list, err := s.db.PolicyClient.ListRegisteredResources(s.ctx, ®isteredresources.ListRegisteredResourcesRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + }, + }) + s.Require().NoError(err) + s.NotNil(list) + items := list.GetResources() + s.Len(items, int(limit)) + + // request with one below maximum + list, err = s.db.PolicyClient.ListRegisteredResources(s.ctx, ®isteredresources.ListRegisteredResourcesRequest{ + Pagination: &policy.PageRequest{ + Limit: s.db.LimitMax - 1, + }, + }) + s.Require().NoError(err) + s.NotNil(list) + + // exactly maximum + list, err = s.db.PolicyClient.ListRegisteredResources(s.ctx, ®isteredresources.ListRegisteredResourcesRequest{ + Pagination: &policy.PageRequest{ + Limit: s.db.LimitMax, + }, + }) + s.Require().NoError(err) + s.NotNil(list) +} + +func (s *NamespacesSuite) Test_ListRegisteredResources_Limit_TooLarge_Fails() { + listRsp, err := s.db.PolicyClient.ListRegisteredResources(s.ctx, ®isteredresources.ListRegisteredResourcesRequest{ + Pagination: &policy.PageRequest{ + Limit: s.db.LimitMax + 1, + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrListLimitTooLarge) + s.Nil(listRsp) +} + +func (s *AttributesSuite) Test_ListRegisteredResources_Offset_Succeeds() { + req := ®isteredresources.ListRegisteredResourcesRequest{} + // make initial list request to compare against + list, err := s.db.PolicyClient.ListRegisteredResources(s.ctx, req) + s.Require().NoError(err) + s.NotNil(list) + items := list.GetResources() + + // set the offset pagination + offset := 2 + req.Pagination = &policy.PageRequest{ + Offset: int32(offset), + } + offsetList, err := s.db.PolicyClient.ListRegisteredResources(s.ctx, req) + s.Require().NoError(err) + s.NotNil(offsetList) + offsetItems := offsetList.GetResources() + + // length is reduced by the offset amount + s.Len(offsetItems, len(items)-offset) + + // objects are equal between offset and original list beginning at offset index + for i, attr := range offsetItems { + s.True(proto.Equal(attr, items[i+offset])) + } +} + +// Update + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResource_Succeeds() { + fixedLabel := "fixed label" + updateLabel := "update label" + updatedLabel := "true" + newLabel := "new label" + + labels := map[string]string{ + "fixed": fixedLabel, + "update": updateLabel, + } + updateLabels := map[string]string{ + "update": updatedLabel, + "new": newLabel, + } + expectedLabels := map[string]string{ + "fixed": fixedLabel, + "update": updatedLabel, + "new": newLabel, + } + + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_update_res", + Metadata: &common.MetadataMutable{ + Labels: labels, + }, + }) + s.Require().NoError(err) + s.NotNil(created) + + // update with no changes + updated, err := s.db.PolicyClient.UpdateRegisteredResource(s.ctx, ®isteredresources.UpdateRegisteredResourceRequest{ + Id: created.GetId(), + }) + s.Require().NoError(err) + s.NotNil(updated) + + // verify resource not updated + got, err := s.db.PolicyClient.GetRegisteredResource(s.ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: created.GetId(), + }, + }) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(created.GetName(), got.GetName()) + s.EqualValues(labels, got.GetMetadata().GetLabels()) + + // update with changes + updated, err = s.db.PolicyClient.UpdateRegisteredResource(s.ctx, ®isteredresources.UpdateRegisteredResourceRequest{ + Id: created.GetId(), + Name: "test_update_res__new_name", + Metadata: &common.MetadataMutable{ + Labels: updateLabels, + }, + MetadataUpdateBehavior: common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_EXTEND, + }) + s.Require().NoError(err) + s.NotNil(updated) + + // verify resource updated + got, err = s.db.PolicyClient.GetRegisteredResource(s.ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: created.GetId(), + }, + }) + s.Require().NoError(err) + s.NotNil(got) + s.Equal("test_update_res__new_name", got.GetName()) + s.EqualValues(expectedLabels, got.GetMetadata().GetLabels()) + metadata := got.GetMetadata() + createdAt := metadata.GetCreatedAt() + updatedAt := metadata.GetUpdatedAt() + s.False(createdAt.AsTime().IsZero()) + s.False(updatedAt.AsTime().IsZero()) + s.True(updatedAt.AsTime().After(createdAt.AsTime())) +} + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResource_NormalizedName_Succeeds() { + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_update_res_normalized", + }) + s.Require().NoError(err) + s.NotNil(created) + + updated, err := s.db.PolicyClient.UpdateRegisteredResource(s.ctx, ®isteredresources.UpdateRegisteredResourceRequest{ + Id: created.GetId(), + Name: "TeST_UpDaTe_RES_NorMa-LiZeD", + }) + s.Require().NoError(err) + s.NotNil(updated) + + // verify resource updated + got, err := s.db.PolicyClient.GetRegisteredResource(s.ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: created.GetId(), + }, + }) + s.Require().NoError(err) + s.NotNil(got) + s.Equal("test_update_res_norma-lized", got.GetName()) +} + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResource_InvalidID_Fails() { + updated, err := s.db.PolicyClient.UpdateRegisteredResource(s.ctx, ®isteredresources.UpdateRegisteredResourceRequest{ + Id: invalidID, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(updated) +} + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResource_NonUniqueName_Fails() { + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_update_res_non_unique", + }) + s.Require().NoError(err) + s.NotNil(created) + + existingRes := s.f.GetRegisteredResourceKey("res_only") + updated, err := s.db.PolicyClient.UpdateRegisteredResource(s.ctx, ®isteredresources.UpdateRegisteredResourceRequest{ + Id: created.GetId(), + Name: existingRes.Name, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrUniqueConstraintViolation) + s.Nil(updated) +} + +// Delete + +func (s *RegisteredResourcesSuite) Test_DeleteRegisteredResource_Succeeds() { + created, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_delete_res", + Values: []string{ + "test_delete_value1", + "test_delete_value2", + }, + }) + s.Require().NoError(err) + + deleted, err := s.db.PolicyClient.DeleteRegisteredResource(s.ctx, created.GetId()) + s.Require().NoError(err) + s.Require().Equal(created.GetId(), deleted.GetId()) + + // verify resource deleted + got, err := s.db.PolicyClient.GetRegisteredResource(s.ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: created.GetId(), + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(got) + + // verify resource values deleted + gotValues := created.GetValues() + + gotValue1, err := s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: gotValues[0].GetId(), + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(gotValue1) + + gotValue2, err := s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: gotValues[1].GetId(), + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(gotValue2) +} + +func (s *RegisteredResourcesSuite) Test_DeleteRegisteredResource_WithInvalidID_Fails() { + deleted, err := s.db.PolicyClient.DeleteRegisteredResource(s.ctx, invalidID) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(deleted) +} + +/// +/// Registered Resource Values +/// + +// Create + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResourceValue_Succeeds() { + res, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_create_res_value", + }) + s.Require().NoError(err) + s.NotNil(res) + + req := ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "value", + } + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, req) + s.Require().NoError(err) + s.NotNil(created) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResourceValue_NormalizedName_Succeeds() { + res, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_create_res_value_normalized", + }) + s.Require().NoError(err) + s.NotNil(res) + + req := ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "VaLuE_NorMa-LiZeD", + } + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, req) + s.Require().NoError(err) + s.NotNil(created) + s.Equal(strings.ToLower(req.GetValue()), created.GetValue()) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResourceValue_WithMetadata_Succeeds() { + res, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_create_res_value_metadata", + }) + s.Require().NoError(err) + s.NotNil(res) + + req := ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "test_create_res_value_metadata", + Metadata: &common.MetadataMutable{ + Labels: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + } + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, req) + s.Require().NoError(err) + s.NotNil(created) + s.Require().Len(created.GetMetadata().GetLabels(), 2) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResourceValue_WithInvalidResource_Fails() { + req := ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: invalidID, + Value: "test_create_res_value__invalid_resource", + } + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, req) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrForeignKeyViolation) + s.Nil(created) +} + +func (s *RegisteredResourcesSuite) Test_CreateRegisteredResourceValue_WithNonUniqueResourceAndValue_Fails() { + existingRes := s.f.GetRegisteredResourceKey("res_with_values") + existingResValue := s.f.GetRegisteredResourceValueKey("res_with_values__value1") + + req := ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: existingRes.ID, + Value: existingResValue.Value, + } + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, req) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrUniqueConstraintViolation) + s.Nil(created) +} + +// Get + +func (s *RegisteredResourcesSuite) Test_GetRegisteredResourceValue_Succeeds() { + existingRes := s.f.GetRegisteredResourceKey("res_with_values") + existingResValue1 := s.f.GetRegisteredResourceValueKey("res_with_values__value1") + + got, err := s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: existingResValue1.ID, + }, + }) + s.Require().NoError(err) + s.NotNil(got) + s.Equal(existingRes.ID, got.GetResource().GetId()) + s.Equal(existingResValue1.Value, got.GetValue()) + metadata := got.GetMetadata() + s.False(metadata.GetCreatedAt().AsTime().IsZero()) + s.False(metadata.GetUpdatedAt().AsTime().IsZero()) +} + +func (s *RegisteredResourcesSuite) Test_GetRegisteredResourceValue_InvalidID_Fails() { + got, err := s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: invalidID, + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(got) +} + +// List + +func (s *RegisteredResourcesSuite) Test_ListRegisteredResourceValues_NoPagination_Succeeds() { + existingRes := s.f.GetRegisteredResourceKey("res_with_values") + existingResValue1 := s.f.GetRegisteredResourceValueKey("res_with_values__value1") + existingResValue2 := s.f.GetRegisteredResourceValueKey("res_with_values__value2") + + list, err := s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, ®isteredresources.ListRegisteredResourceValuesRequest{}) + s.Require().NoError(err) + s.NotNil(list) + // should be more values than the 2 explicitly tested below + s.Greater(len(list.GetValues()), 2) + + foundCount := 0 + + for _, r := range list.GetValues() { + if r.GetId() == existingResValue1.ID { + foundCount++ + s.Equal(existingResValue1.Value, r.GetValue()) + s.Equal(existingRes.ID, r.GetResource().GetId()) + metadata := r.GetMetadata() + s.False(metadata.GetCreatedAt().AsTime().IsZero()) + s.False(metadata.GetUpdatedAt().AsTime().IsZero()) + } + + if r.GetId() == existingResValue2.ID { + foundCount++ + s.Equal(existingResValue2.Value, r.GetValue()) + s.Equal(existingRes.ID, r.GetResource().GetId()) + metadata := r.GetMetadata() + s.False(metadata.GetCreatedAt().AsTime().IsZero()) + s.False(metadata.GetUpdatedAt().AsTime().IsZero()) + } + } + + s.Equal(2, foundCount) +} + +func (s *RegisteredResourcesSuite) Test_ListRegisteredResourceValues_Limit_Succeeds() { + var limit int32 = 1 + list, err := s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, ®isteredresources.ListRegisteredResourceValuesRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + }, + }) + s.Require().NoError(err) + s.NotNil(list) + items := list.GetValues() + s.Len(items, int(limit)) + + // request with one below maximum + list, err = s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, ®isteredresources.ListRegisteredResourceValuesRequest{ + Pagination: &policy.PageRequest{ + Limit: s.db.LimitMax - 1, + }, + }) + s.Require().NoError(err) + s.NotNil(list) + + // exactly maximum + list, err = s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, ®isteredresources.ListRegisteredResourceValuesRequest{ + Pagination: &policy.PageRequest{ + Limit: s.db.LimitMax, + }, + }) + s.Require().NoError(err) + s.NotNil(list) +} + +func (s *NamespacesSuite) Test_ListRegisteredResourceValues_Limit_TooLarge_Fails() { + listRsp, err := s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, ®isteredresources.ListRegisteredResourceValuesRequest{ + Pagination: &policy.PageRequest{ + Limit: s.db.LimitMax + 1, + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrListLimitTooLarge) + s.Nil(listRsp) +} + +func (s *AttributesSuite) Test_ListRegisteredResourceValues_Offset_Succeeds() { + req := ®isteredresources.ListRegisteredResourceValuesRequest{} + // make initial list request to compare against + list, err := s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, req) + s.Require().NoError(err) + s.NotNil(list) + items := list.GetValues() + + // set the offset pagination + offset := 2 + req.Pagination = &policy.PageRequest{ + Offset: int32(offset), + } + offsetList, err := s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, req) + s.Require().NoError(err) + s.NotNil(offsetList) + offsetItems := offsetList.GetValues() + + // length is reduced by the offset amount + s.Len(offsetItems, len(items)-offset) + + // objects are equal between offset and original list beginning at offset index + for i, attr := range offsetItems { + s.True(proto.Equal(attr, items[i+offset])) + } +} + +func (s *RegisteredResourcesSuite) Test_ListRegisteredResourceValues_ByResourceID_Succeeds() { + existingRes := s.f.GetRegisteredResourceKey("res_with_values") + existingResValue1 := s.f.GetRegisteredResourceValueKey("res_with_values__value1") + existingResValue2 := s.f.GetRegisteredResourceValueKey("res_with_values__value2") + + list, err := s.db.PolicyClient.ListRegisteredResourceValues(s.ctx, ®isteredresources.ListRegisteredResourceValuesRequest{ + ResourceId: existingRes.ID, + }) + s.Require().NoError(err) + s.NotNil(list) + // should only be the 2 values associated with the resource + s.Len(list.GetValues(), 2) + + foundCount := 0 + + for _, r := range list.GetValues() { + if r.GetId() == existingResValue1.ID || r.GetId() == existingResValue2.ID { + foundCount++ + } + } + + s.Equal(2, foundCount) +} + +// Update + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResourceValue_Succeeds() { + fixedLabel := "fixed label" + updateLabel := "update label" + updatedLabel := "true" + newLabel := "new label" + + labels := map[string]string{ + "fixed": fixedLabel, + "update": updateLabel, + } + updateLabels := map[string]string{ + "update": updatedLabel, + "new": newLabel, + } + expectedLabels := map[string]string{ + "fixed": fixedLabel, + "update": updatedLabel, + "new": newLabel, + } + + res, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_update_res_value", + }) + s.Require().NoError(err) + s.NotNil(res) + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "value", + Metadata: &common.MetadataMutable{ + Labels: labels, + }, + }) + s.Require().NoError(err) + s.NotNil(created) + + // update with no changes + updated, err := s.db.PolicyClient.UpdateRegisteredResourceValue(s.ctx, ®isteredresources.UpdateRegisteredResourceValueRequest{ + Id: created.GetId(), + }) + s.Require().NoError(err) + s.NotNil(updated) + + // verify resource value not updated + got, err := s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: created.GetId(), + }, + }) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(created.GetValue(), got.GetValue()) + s.EqualValues(labels, got.GetMetadata().GetLabels()) + + // update with changes + updated, err = s.db.PolicyClient.UpdateRegisteredResourceValue(s.ctx, ®isteredresources.UpdateRegisteredResourceValueRequest{ + Id: created.GetId(), + Value: "updated_value", + Metadata: &common.MetadataMutable{ + Labels: updateLabels, + }, + MetadataUpdateBehavior: common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_EXTEND, + }) + s.Require().NoError(err) + s.NotNil(updated) + + // verify resource updated + got, err = s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: created.GetId(), + }, + }) + s.Require().NoError(err) + s.NotNil(got) + s.Equal("updated_value", got.GetValue()) + s.EqualValues(expectedLabels, got.GetMetadata().GetLabels()) + metadata := got.GetMetadata() + createdAt := metadata.GetCreatedAt() + updatedAt := metadata.GetUpdatedAt() + s.False(createdAt.AsTime().IsZero()) + s.False(updatedAt.AsTime().IsZero()) + s.True(updatedAt.AsTime().After(createdAt.AsTime())) +} + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResourceValue_NormalizedName_Succeeds() { + res, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_update_res_value_normalized", + }) + s.Require().NoError(err) + s.NotNil(res) + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "value_normalized", + }) + s.Require().NoError(err) + s.NotNil(created) + + updated, err := s.db.PolicyClient.UpdateRegisteredResourceValue(s.ctx, ®isteredresources.UpdateRegisteredResourceValueRequest{ + Id: created.GetId(), + Value: "VaLuE_NorMa-LiZeD", + }) + s.Require().NoError(err) + s.NotNil(updated) + + // verify resource value updated + got, err := s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: created.GetId(), + }, + }) + s.Require().NoError(err) + s.NotNil(got) + s.Equal("value_norma-lized", got.GetValue()) +} + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResourceValue_InvalidID_Fails() { + updated, err := s.db.PolicyClient.UpdateRegisteredResourceValue(s.ctx, ®isteredresources.UpdateRegisteredResourceValueRequest{ + Id: invalidID, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(updated) +} + +func (s *RegisteredResourcesSuite) Test_UpdateRegisteredResourceValue_NonUniqueResourceAndValue_Fails() { + res, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_update_res_value_non_unique", + }) + s.Require().NoError(err) + s.NotNil(res) + + resVal1, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "value1", + }) + s.Require().NoError(err) + s.NotNil(resVal1) + + resVal2, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "value2", + }) + s.Require().NoError(err) + s.NotNil(resVal2) + + updated, err := s.db.PolicyClient.UpdateRegisteredResourceValue(s.ctx, ®isteredresources.UpdateRegisteredResourceValueRequest{ + Id: resVal1.GetId(), + // causes unique constraint violation attempting to update value1 to value2 + Value: resVal2.GetValue(), + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrUniqueConstraintViolation) + s.Nil(updated) +} + +// Delete + +func (s *RegisteredResourcesSuite) Test_DeleteRegisteredResourceValue_Succeeds() { + res, err := s.db.PolicyClient.CreateRegisteredResource(s.ctx, ®isteredresources.CreateRegisteredResourceRequest{ + Name: "test_delete_res_value", + }) + s.Require().NoError(err) + s.NotNil(res) + + created, err := s.db.PolicyClient.CreateRegisteredResourceValue(s.ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: res.GetId(), + Value: "value", + }) + s.Require().NoError(err) + + deleted, err := s.db.PolicyClient.DeleteRegisteredResourceValue(s.ctx, created.GetId()) + s.Require().NoError(err) + s.Require().Equal(created.GetId(), deleted.GetId()) + + // verify resource value deleted + + got, err := s.db.PolicyClient.GetRegisteredResourceValue(s.ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: created.GetId(), + }, + }) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(got) +} + +func (s *RegisteredResourcesSuite) Test_DeleteRegisteredResourceValue_WithInvalidID_Fails() { + deleted, err := s.db.PolicyClient.DeleteRegisteredResourceValue(s.ctx, invalidID) + s.Require().Error(err) + s.Require().ErrorIs(err, db.ErrNotFound) + s.Nil(deleted) +} diff --git a/service/internal/fixtures/fixtures.go b/service/internal/fixtures/fixtures.go index 4e0573415e..58a6eae714 100644 --- a/service/internal/fixtures/fixtures.go +++ b/service/internal/fixtures/fixtures.go @@ -126,6 +126,17 @@ type FixtureDataNamespaceKeyMap struct { KeyID string `yaml:"key_id"` } +type FixtureDataRegisteredResource struct { + ID string `yaml:"id"` + Name string `yaml:"name"` +} + +type FixtureDataRegisteredResourceValue struct { + ID string `yaml:"id"` + RegisteredResourceID string `yaml:"registered_resource_id"` + Value string `yaml:"value"` +} + type FixtureData struct { Namespaces struct { Metadata FixtureMetadata `yaml:"metadata"` @@ -177,6 +188,14 @@ type FixtureData struct { Metadata FixtureMetadata `yaml:"metadata"` Data []FixtureDataNamespaceKeyMap `yaml:"data"` } `yaml:"namespace_key_map"` + RegisteredResources struct { + Metadata FixtureMetadata `yaml:"metadata"` + Data map[string]FixtureDataRegisteredResource `yaml:"data"` + } `yaml:"registered_resources"` + RegisteredResourceValues struct { + Metadata FixtureMetadata `yaml:"metadata"` + Data map[string]FixtureDataRegisteredResourceValue `yaml:"data"` + } `yaml:"registered_resource_values"` } func LoadFixtureData(file string) { @@ -314,6 +333,24 @@ func (f *Fixtures) GetNamespaceKeyMap(key string) []FixtureDataNamespaceKeyMap { return nkms } +func (f *Fixtures) GetRegisteredResourceKey(key string) FixtureDataRegisteredResource { + rr, ok := fixtureData.RegisteredResources.Data[key] + if !ok || rr.ID == "" { + slog.Error("could not find registered resource", slog.String("id", key)) + panic("could not find registered resource fixture: " + key) + } + return rr +} + +func (f *Fixtures) GetRegisteredResourceValueKey(key string) FixtureDataRegisteredResourceValue { + rv, ok := fixtureData.RegisteredResourceValues.Data[key] + if !ok || rv.ID == "" { + slog.Error("could not find registered resource value", slog.String("id", key)) + panic("could not find registered resource value fixture: " + key) + } + return rv +} + func (f *Fixtures) Provision() { slog.Info("📦 running migrations in schema", slog.String("schema", f.db.Schema)) _, err := f.db.Client.RunMigrations(context.Background(), policy.Migrations) @@ -349,6 +386,10 @@ func (f *Fixtures) Provision() { dkm := f.provisionDefinitionKeyMap() slog.Info("📦 provisioning namespace key map") nkm := f.provisionNamespaceKeyMap() + slog.Info("📦 provisioning registered resources") + rr := f.provisionRegisteredResources() + slog.Info("📦 provisioning registered resource values") + rrv := f.provisionRegisteredResourceValues() slog.Info("📦 provisioned fixtures data", slog.Int64("namespaces", n), @@ -365,6 +406,8 @@ func (f *Fixtures) Provision() { slog.Int64("value_key_map", vkm), slog.Int64("definition_key_map", dkm), slog.Int64("namespace_key_map", nkm), + slog.Int64("registered_resources", rr), + slog.Int64("registered_resource_values", rrv), ) slog.Info("📚 indexing FQNs for fixtures") f.db.PolicyClient.AttrFqnReindex(context.Background()) @@ -573,6 +616,29 @@ func (f *Fixtures) provisionNamespaceKeyMap() int64 { return f.provision(fixtureData.NamespaceKeyMap.Metadata.TableName, fixtureData.NamespaceKeyMap.Metadata.Columns, values) } +func (f *Fixtures) provisionRegisteredResources() int64 { + values := make([][]string, 0, len(fixtureData.RegisteredResources.Data)) + for _, d := range fixtureData.RegisteredResources.Data { + values = append(values, []string{ + f.db.StringWrap(d.ID), + f.db.StringWrap(d.Name), + }) + } + return f.provision(fixtureData.RegisteredResources.Metadata.TableName, fixtureData.RegisteredResources.Metadata.Columns, values) +} + +func (f *Fixtures) provisionRegisteredResourceValues() int64 { + values := make([][]string, 0, len(fixtureData.RegisteredResourceValues.Data)) + for _, d := range fixtureData.RegisteredResourceValues.Data { + values = append(values, []string{ + f.db.StringWrap(d.ID), + f.db.StringWrap(d.RegisteredResourceID), + f.db.StringWrap(d.Value), + }) + } + return f.provision(fixtureData.RegisteredResourceValues.Metadata.TableName, fixtureData.RegisteredResourceValues.Metadata.Columns, values) +} + func (f *Fixtures) provision(t string, c []string, v [][]string) int64 { rows, err := f.db.ExecInsert(t, c, v...) if err != nil { diff --git a/service/internal/fixtures/policy_fixtures.yaml b/service/internal/fixtures/policy_fixtures.yaml index 1b884ab1c1..9f4f82612a 100644 --- a/service/internal/fixtures/policy_fixtures.yaml +++ b/service/internal/fixtures/policy_fixtures.yaml @@ -615,3 +615,49 @@ namespace_key_map: key_id: 68fb7e3e-6c7b-434d-bf38-07701ad8655a - namespace_id: 87ba60e1-da12-4889-95fd-267968bf0896 # deactivated key_id: d433e903-e3d1-4823-9b3e-7b188e7c6060 + +## +# Registered Resources +# +## +registered_resources: + metadata: + table_name: registered_resources + columns: + - id + - name + data: + res_with_values: + id: f3a1b2c4-5d6e-7f89-0a1b-2c3d4e5f6789 + name: res_with_values + res_with_values2: + id: 39cd944b-d703-4330-936a-83b3d497c8d4 + name: res_with_values2 + res_only: + id: a9b8c7d6-e5f4-3a2b-1c0d-9e8f7a6b5c4d + name: res_only + +## +# Registered Resource Values +# +## +registered_resource_values: + metadata: + table_name: registered_resource_values + columns: + - id + - registered_resource_id + - value + data: + res_with_values__value1: + id: 1d2c3b4a-5e6f-7a89-0b1c-2d3e4f5a6789 + registered_resource_id: f3a1b2c4-5d6e-7f89-0a1b-2c3d4e5f6789 + value: res_with_values__value1 + res_with_values__value2: + id: 9e8f7a6b-5c4d-3b2a-1d0c-8f7e6a5b4c3d + registered_resource_id: f3a1b2c4-5d6e-7f89-0a1b-2c3d4e5f6789 + value: res_with_values__value2 + res_with_values2__value1: + id: a932ff01-cca6-41f6-a147-7eba7560611b + registered_resource_id: 39cd944b-d703-4330-936a-83b3d497c8d4 + value: res_with_values2__value1 diff --git a/service/policy/db/db.go b/service/policy/db/db.go index b4a3b78a36..5b8c8f5307 100644 --- a/service/policy/db/db.go +++ b/service/policy/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.26.0 package db diff --git a/service/policy/db/models.go b/service/policy/db/models.go index eecd59b5d6..a0cec0a782 100644 --- a/service/policy/db/models.go +++ b/service/policy/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.26.0 package db @@ -225,6 +225,36 @@ type PublicKey struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } +// Table to store registered resources +type RegisteredResource struct { + // Primary key for the table + ID string `json:"id"` + // Name for the registered resource + Name string `json:"name"` + // Metadata for the registered resource (see protos for structure) + Metadata []byte `json:"metadata"` + // Timestamp when the record was created + CreatedAt pgtype.Timestamptz `json:"created_at"` + // Timestamp when the record was last updated + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +// Table to store registered resource values +type RegisteredResourceValue struct { + // Primary key for the table + ID string `json:"id"` + // Foreign key to the registered_resources table + RegisteredResourceID string `json:"registered_resource_id"` + // Value for the registered resource value + Value string `json:"value"` + // Metadata for the registered resource value (see protos for structure) + Metadata []byte `json:"metadata"` + // Timestamp when the record was created + CreatedAt pgtype.Timestamptz `json:"created_at"` + // Timestamp when the record was last updated + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + // Table to store associated terms that should map resource data to attribute values type ResourceMapping struct { // Primary key for the table diff --git a/service/policy/db/query.sql b/service/policy/db/query.sql index 17fb3c83ee..05ea999bf9 100644 --- a/service/policy/db/query.sql +++ b/service/policy/db/query.sql @@ -1139,4 +1139,111 @@ INSERT INTO attribute_value_public_key_map (value_id, key_id) VALUES ($1, $2); -- name: removePublicKeyFromAttributeValue :execrows DELETE FROM attribute_value_public_key_map WHERE value_id = $1 AND key_id = $2; + +---------------------------------------------------------------- +-- REGISTERED RESOURCES ---------------------------------------------------------------- + +-- name: createRegisteredResource :one +INSERT INTO registered_resources (name, metadata) +VALUES ($1, $2) +RETURNING id; + +-- name: getRegisteredResource :one +-- TODO add FQN support +SELECT + r.id, + r.name, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', v.id, + 'value', v.value + ) + ) FILTER (WHERE v.id IS NOT NULL) as values +FROM registered_resources r +LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +WHERE r.id = $1 +GROUP BY r.id; + +-- name: listRegisteredResources :many +WITH counted AS ( + SELECT COUNT(id) AS total + FROM registered_resources +) +SELECT + r.id, + r.name, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', v.id, + 'value', v.value + ) + ) FILTER (WHERE v.id IS NOT NULL) as values, + counted.total +FROM registered_resources r +CROSS JOIN counted +LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +GROUP BY r.id, counted.total +LIMIT @limit_ +OFFSET @offset_; + +-- name: updateRegisteredResource :execrows +UPDATE registered_resources +SET + name = COALESCE(sqlc.narg('name'), name), + metadata = COALESCE(sqlc.narg('metadata'), metadata) +WHERE id = $1; + +-- name: deleteRegisteredResource :execrows +DELETE FROM registered_resources WHERE id = $1; + + +---------------------------------------------------------------- +-- REGISTERED RESOURCE VALUES +---------------------------------------------------------------- + +-- name: createRegisteredResourceValue :one +INSERT INTO registered_resource_values (registered_resource_id, value, metadata) +VALUES ($1, $2, $3) +RETURNING id; + +-- name: getRegisteredResourceValue :one +SELECT + id, + registered_resource_id, + value, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', metadata -> 'labels', 'created_at', created_at, 'updated_at', updated_at)) as metadata +FROM registered_resource_values +WHERE id = $1; + +-- name: listRegisteredResourceValues :many +WITH counted AS ( + SELECT COUNT(id) AS total + FROM registered_resource_values + WHERE + NULLIF(@registered_resource_id, '') IS NULL OR registered_resource_id = @registered_resource_id::UUID +) +SELECT + id, + registered_resource_id, + value, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', metadata -> 'labels', 'created_at', created_at, 'updated_at', updated_at)) as metadata, + counted.total +FROM registered_resource_values +CROSS JOIN counted +WHERE + NULLIF(@registered_resource_id, '') IS NULL OR registered_resource_id = @registered_resource_id::UUID +LIMIT @limit_ +OFFSET @offset_; + +-- name: updateRegisteredResourceValue :execrows +UPDATE registered_resource_values +SET + value = COALESCE(sqlc.narg('value'), value), + metadata = COALESCE(sqlc.narg('metadata'), metadata) +WHERE id = $1; + +-- name: deleteRegisteredResourceValue :execrows +DELETE FROM registered_resource_values WHERE id = $1; diff --git a/service/policy/db/query.sql.go b/service/policy/db/query.sql.go index 2da4cb5110..b0da734a0f 100644 --- a/service/policy/db/query.sql.go +++ b/service/policy/db/query.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.26.0 // source: query.sql package db @@ -3308,6 +3308,59 @@ func (q *Queries) createPublicKey(ctx context.Context, arg createPublicKeyParams return id, err } +const createRegisteredResource = `-- name: createRegisteredResource :one + +INSERT INTO registered_resources (name, metadata) +VALUES ($1, $2) +RETURNING id +` + +type createRegisteredResourceParams struct { + Name string `json:"name"` + Metadata []byte `json:"metadata"` +} + +// -------------------------------------------------------------- +// REGISTERED RESOURCES +// -------------------------------------------------------------- +// +// INSERT INTO registered_resources (name, metadata) +// VALUES ($1, $2) +// RETURNING id +func (q *Queries) createRegisteredResource(ctx context.Context, arg createRegisteredResourceParams) (string, error) { + row := q.db.QueryRow(ctx, createRegisteredResource, arg.Name, arg.Metadata) + var id string + err := row.Scan(&id) + return id, err +} + +const createRegisteredResourceValue = `-- name: createRegisteredResourceValue :one + +INSERT INTO registered_resource_values (registered_resource_id, value, metadata) +VALUES ($1, $2, $3) +RETURNING id +` + +type createRegisteredResourceValueParams struct { + RegisteredResourceID string `json:"registered_resource_id"` + Value string `json:"value"` + Metadata []byte `json:"metadata"` +} + +// -------------------------------------------------------------- +// REGISTERED RESOURCE VALUES +// -------------------------------------------------------------- +// +// INSERT INTO registered_resource_values (registered_resource_id, value, metadata) +// VALUES ($1, $2, $3) +// RETURNING id +func (q *Queries) createRegisteredResourceValue(ctx context.Context, arg createRegisteredResourceValueParams) (string, error) { + row := q.db.QueryRow(ctx, createRegisteredResourceValue, arg.RegisteredResourceID, arg.Value, arg.Metadata) + var id string + err := row.Scan(&id) + return id, err +} + const deactivatePublicKey = `-- name: deactivatePublicKey :execrows UPDATE public_keys SET is_active = FALSE WHERE id = $1 ` @@ -3338,6 +3391,36 @@ func (q *Queries) deletePublicKey(ctx context.Context, id string) (int64, error) return result.RowsAffected(), nil } +const deleteRegisteredResource = `-- name: deleteRegisteredResource :execrows +DELETE FROM registered_resources WHERE id = $1 +` + +// deleteRegisteredResource +// +// DELETE FROM registered_resources WHERE id = $1 +func (q *Queries) deleteRegisteredResource(ctx context.Context, id string) (int64, error) { + result, err := q.db.Exec(ctx, deleteRegisteredResource, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteRegisteredResourceValue = `-- name: deleteRegisteredResourceValue :execrows +DELETE FROM registered_resource_values WHERE id = $1 +` + +// deleteRegisteredResourceValue +// +// DELETE FROM registered_resource_values WHERE id = $1 +func (q *Queries) deleteRegisteredResourceValue(ctx context.Context, id string) (int64, error) { + result, err := q.db.Exec(ctx, deleteRegisteredResourceValue, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const getPublicKey = `-- name: getPublicKey :one SELECT k.id, @@ -3402,6 +3485,96 @@ func (q *Queries) getPublicKey(ctx context.Context, id string) (getPublicKeyRow, return i, err } +const getRegisteredResource = `-- name: getRegisteredResource :one +SELECT + r.id, + r.name, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', v.id, + 'value', v.value + ) + ) FILTER (WHERE v.id IS NOT NULL) as values +FROM registered_resources r +LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +WHERE r.id = $1 +GROUP BY r.id +` + +type getRegisteredResourceRow struct { + ID string `json:"id"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Values []byte `json:"values"` +} + +// TODO add FQN support +// +// SELECT +// r.id, +// r.name, +// JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, +// JSON_AGG( +// JSON_BUILD_OBJECT( +// 'id', v.id, +// 'value', v.value +// ) +// ) FILTER (WHERE v.id IS NOT NULL) as values +// FROM registered_resources r +// LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +// WHERE r.id = $1 +// GROUP BY r.id +func (q *Queries) getRegisteredResource(ctx context.Context, id string) (getRegisteredResourceRow, error) { + row := q.db.QueryRow(ctx, getRegisteredResource, id) + var i getRegisteredResourceRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Metadata, + &i.Values, + ) + return i, err +} + +const getRegisteredResourceValue = `-- name: getRegisteredResourceValue :one +SELECT + id, + registered_resource_id, + value, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', metadata -> 'labels', 'created_at', created_at, 'updated_at', updated_at)) as metadata +FROM registered_resource_values +WHERE id = $1 +` + +type getRegisteredResourceValueRow struct { + ID string `json:"id"` + RegisteredResourceID string `json:"registered_resource_id"` + Value string `json:"value"` + Metadata []byte `json:"metadata"` +} + +// getRegisteredResourceValue +// +// SELECT +// id, +// registered_resource_id, +// value, +// JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', metadata -> 'labels', 'created_at', created_at, 'updated_at', updated_at)) as metadata +// FROM registered_resource_values +// WHERE id = $1 +func (q *Queries) getRegisteredResourceValue(ctx context.Context, id string) (getRegisteredResourceValueRow, error) { + row := q.db.QueryRow(ctx, getRegisteredResourceValue, id) + var i getRegisteredResourceValueRow + err := row.Scan( + &i.ID, + &i.RegisteredResourceID, + &i.Value, + &i.Metadata, + ) + return i, err +} + const listPublicKeyMappings = `-- name: listPublicKeyMappings :many WITH counted AS ( SELECT COUNT(DISTINCT kas.id) AS total FROM public_keys AS pk @@ -3791,6 +3964,173 @@ func (q *Queries) listPublicKeys(ctx context.Context, arg listPublicKeysParams) return items, nil } +const listRegisteredResourceValues = `-- name: listRegisteredResourceValues :many +WITH counted AS ( + SELECT COUNT(id) AS total + FROM registered_resource_values + WHERE + NULLIF($1, '') IS NULL OR registered_resource_id = $1::UUID +) +SELECT + id, + registered_resource_id, + value, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', metadata -> 'labels', 'created_at', created_at, 'updated_at', updated_at)) as metadata, + counted.total +FROM registered_resource_values +CROSS JOIN counted +WHERE + NULLIF($1, '') IS NULL OR registered_resource_id = $1::UUID +LIMIT $3 +OFFSET $2 +` + +type listRegisteredResourceValuesParams struct { + RegisteredResourceID interface{} `json:"registered_resource_id"` + Offset int32 `json:"offset_"` + Limit int32 `json:"limit_"` +} + +type listRegisteredResourceValuesRow struct { + ID string `json:"id"` + RegisteredResourceID string `json:"registered_resource_id"` + Value string `json:"value"` + Metadata []byte `json:"metadata"` + Total int64 `json:"total"` +} + +// listRegisteredResourceValues +// +// WITH counted AS ( +// SELECT COUNT(id) AS total +// FROM registered_resource_values +// WHERE +// NULLIF($1, '') IS NULL OR registered_resource_id = $1::UUID +// ) +// SELECT +// id, +// registered_resource_id, +// value, +// JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', metadata -> 'labels', 'created_at', created_at, 'updated_at', updated_at)) as metadata, +// counted.total +// FROM registered_resource_values +// CROSS JOIN counted +// WHERE +// NULLIF($1, '') IS NULL OR registered_resource_id = $1::UUID +// LIMIT $3 +// OFFSET $2 +func (q *Queries) listRegisteredResourceValues(ctx context.Context, arg listRegisteredResourceValuesParams) ([]listRegisteredResourceValuesRow, error) { + rows, err := q.db.Query(ctx, listRegisteredResourceValues, arg.RegisteredResourceID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []listRegisteredResourceValuesRow + for rows.Next() { + var i listRegisteredResourceValuesRow + if err := rows.Scan( + &i.ID, + &i.RegisteredResourceID, + &i.Value, + &i.Metadata, + &i.Total, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRegisteredResources = `-- name: listRegisteredResources :many +WITH counted AS ( + SELECT COUNT(id) AS total + FROM registered_resources +) +SELECT + r.id, + r.name, + JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', v.id, + 'value', v.value + ) + ) FILTER (WHERE v.id IS NOT NULL) as values, + counted.total +FROM registered_resources r +CROSS JOIN counted +LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +GROUP BY r.id, counted.total +LIMIT $2 +OFFSET $1 +` + +type listRegisteredResourcesParams struct { + Offset int32 `json:"offset_"` + Limit int32 `json:"limit_"` +} + +type listRegisteredResourcesRow struct { + ID string `json:"id"` + Name string `json:"name"` + Metadata []byte `json:"metadata"` + Values []byte `json:"values"` + Total int64 `json:"total"` +} + +// listRegisteredResources +// +// WITH counted AS ( +// SELECT COUNT(id) AS total +// FROM registered_resources +// ) +// SELECT +// r.id, +// r.name, +// JSON_STRIP_NULLS(JSON_BUILD_OBJECT('labels', r.metadata -> 'labels', 'created_at', r.created_at, 'updated_at', r.updated_at)) as metadata, +// JSON_AGG( +// JSON_BUILD_OBJECT( +// 'id', v.id, +// 'value', v.value +// ) +// ) FILTER (WHERE v.id IS NOT NULL) as values, +// counted.total +// FROM registered_resources r +// CROSS JOIN counted +// LEFT JOIN registered_resource_values v ON v.registered_resource_id = r.id +// GROUP BY r.id, counted.total +// LIMIT $2 +// OFFSET $1 +func (q *Queries) listRegisteredResources(ctx context.Context, arg listRegisteredResourcesParams) ([]listRegisteredResourcesRow, error) { + rows, err := q.db.Query(ctx, listRegisteredResources, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []listRegisteredResourcesRow + for rows.Next() { + var i listRegisteredResourcesRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Metadata, + &i.Values, + &i.Total, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const removePublicKeyFromAttributeDefinition = `-- name: removePublicKeyFromAttributeDefinition :execrows DELETE FROM attribute_definition_public_key_map WHERE definition_id = $1 AND key_id = $2 ` @@ -3888,3 +4228,61 @@ func (q *Queries) updatePublicKey(ctx context.Context, arg updatePublicKeyParams ) return i, err } + +const updateRegisteredResource = `-- name: updateRegisteredResource :execrows +UPDATE registered_resources +SET + name = COALESCE($2, name), + metadata = COALESCE($3, metadata) +WHERE id = $1 +` + +type updateRegisteredResourceParams struct { + ID string `json:"id"` + Name pgtype.Text `json:"name"` + Metadata []byte `json:"metadata"` +} + +// updateRegisteredResource +// +// UPDATE registered_resources +// SET +// name = COALESCE($2, name), +// metadata = COALESCE($3, metadata) +// WHERE id = $1 +func (q *Queries) updateRegisteredResource(ctx context.Context, arg updateRegisteredResourceParams) (int64, error) { + result, err := q.db.Exec(ctx, updateRegisteredResource, arg.ID, arg.Name, arg.Metadata) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateRegisteredResourceValue = `-- name: updateRegisteredResourceValue :execrows +UPDATE registered_resource_values +SET + value = COALESCE($2, value), + metadata = COALESCE($3, metadata) +WHERE id = $1 +` + +type updateRegisteredResourceValueParams struct { + ID string `json:"id"` + Value pgtype.Text `json:"value"` + Metadata []byte `json:"metadata"` +} + +// updateRegisteredResourceValue +// +// UPDATE registered_resource_values +// SET +// value = COALESCE($2, value), +// metadata = COALESCE($3, metadata) +// WHERE id = $1 +func (q *Queries) updateRegisteredResourceValue(ctx context.Context, arg updateRegisteredResourceValueParams) (int64, error) { + result, err := q.db.Exec(ctx, updateRegisteredResourceValue, arg.ID, arg.Value, arg.Metadata) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/service/policy/db/registered_resources.go b/service/policy/db/registered_resources.go new file mode 100644 index 0000000000..e86dc26b7a --- /dev/null +++ b/service/policy/db/registered_resources.go @@ -0,0 +1,382 @@ +package db + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/service/pkg/db" + "google.golang.org/protobuf/encoding/protojson" +) + +func unmarshalRegisteredResourceValuesProto(valuesJSON []byte, values *[]*policy.RegisteredResourceValue) error { + if valuesJSON == nil { + return nil + } + + raw := []json.RawMessage{} + if err := json.Unmarshal(valuesJSON, &raw); err != nil { + return fmt.Errorf("failed to unmarshal values array [%s]: %w", string(valuesJSON), err) + } + + for _, r := range raw { + v := &policy.RegisteredResourceValue{} + if err := protojson.Unmarshal(r, v); err != nil { + return fmt.Errorf("failed to unmarshal value [%s]: %w", string(r), err) + } + *values = append(*values, v) + } + + return nil +} + +/// +/// Registered Resources +/// + +func (c PolicyDBClient) CreateRegisteredResource(ctx context.Context, r *registeredresources.CreateRegisteredResourceRequest) (*policy.RegisteredResource, error) { + name := strings.ToLower(r.GetName()) + metadataJSON, _, err := db.MarshalCreateMetadata(r.GetMetadata()) + if err != nil { + return nil, err + } + + createdID, err := c.Queries.createRegisteredResource(ctx, createRegisteredResourceParams{ + Name: name, + Metadata: metadataJSON, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + for _, v := range r.GetValues() { + req := ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: createdID, + Value: v, + } + _, err := c.CreateRegisteredResourceValue(ctx, req) + if err != nil { + return nil, err + } + } + + return c.GetRegisteredResource(ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: createdID, + }, + }) +} + +func (c PolicyDBClient) GetRegisteredResource(ctx context.Context, r *registeredresources.GetRegisteredResourceRequest) (*policy.RegisteredResource, error) { + var id string + + switch { + case r.GetResourceId() != "": + // TODO: refactor to pgtype.UUID once the query supports both id and fqn + id = r.GetResourceId() + case r.GetFqn() != "": + // TODO: implement + return nil, errors.New("FQN support not yet implemented") + default: + return nil, db.ErrSelectIdentifierInvalid + } + + rr, err := c.Queries.getRegisteredResource(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + metadata := &common.Metadata{} + if err = unmarshalMetadata(rr.Metadata, metadata); err != nil { + return nil, err + } + + values := []*policy.RegisteredResourceValue{} + if err = unmarshalRegisteredResourceValuesProto(rr.Values, &values); err != nil { + return nil, err + } + + return &policy.RegisteredResource{ + Id: rr.ID, + Name: rr.Name, + Metadata: metadata, + Values: values, + }, nil +} + +func (c PolicyDBClient) ListRegisteredResources(ctx context.Context, r *registeredresources.ListRegisteredResourcesRequest) (*registeredresources.ListRegisteredResourcesResponse, error) { + limit, offset := c.getRequestedLimitOffset(r.GetPagination()) + + maxLimit := c.listCfg.limitMax + if maxLimit > 0 && limit > maxLimit { + return nil, db.ErrListLimitTooLarge + } + + list, err := c.Queries.listRegisteredResources(ctx, listRegisteredResourcesParams{ + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + rrList := make([]*policy.RegisteredResource, len(list)) + + for i, r := range list { + metadata := &common.Metadata{} + if err = unmarshalMetadata(r.Metadata, metadata); err != nil { + return nil, err + } + + values := []*policy.RegisteredResourceValue{} + if err = unmarshalRegisteredResourceValuesProto(r.Values, &values); err != nil { + return nil, err + } + + rrList[i] = &policy.RegisteredResource{ + Id: r.ID, + Name: r.Name, + Metadata: metadata, + Values: values, + } + } + + var total int32 + var nextOffset int32 + if len(list) > 0 { + total = int32(list[0].Total) + nextOffset = getNextOffset(offset, limit, total) + } + + return ®isteredresources.ListRegisteredResourcesResponse{ + Resources: rrList, + Pagination: &policy.PageResponse{ + CurrentOffset: offset, + Total: total, + NextOffset: nextOffset, + }, + }, nil +} + +func (c PolicyDBClient) UpdateRegisteredResource(ctx context.Context, r *registeredresources.UpdateRegisteredResourceRequest) (*policy.RegisteredResource, error) { + id := r.GetId() + name := strings.ToLower(r.GetName()) + metadataJSON, metadata, err := db.MarshalUpdateMetadata(r.GetMetadata(), r.GetMetadataUpdateBehavior(), func() (*common.Metadata, error) { + v, err := c.GetRegisteredResource(ctx, ®isteredresources.GetRegisteredResourceRequest{ + Identifier: ®isteredresources.GetRegisteredResourceRequest_ResourceId{ + ResourceId: id, + }, + }) + if err != nil { + return nil, err + } + return v.GetMetadata(), nil + }) + if err != nil { + return nil, err + } + + count, err := c.Queries.updateRegisteredResource(ctx, updateRegisteredResourceParams{ + ID: id, + Name: pgtypeText(name), + Metadata: metadataJSON, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if count == 0 { + return nil, db.ErrNotFound + } + + return &policy.RegisteredResource{ + Id: id, + Name: name, + Metadata: metadata, + }, nil +} + +func (c PolicyDBClient) DeleteRegisteredResource(ctx context.Context, id string) (*policy.RegisteredResource, error) { + count, err := c.Queries.deleteRegisteredResource(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if count == 0 { + return nil, db.ErrNotFound + } + + return &policy.RegisteredResource{ + Id: id, + }, nil +} + +/// +/// Registered Resource Values +/// + +func (c PolicyDBClient) CreateRegisteredResourceValue(ctx context.Context, r *registeredresources.CreateRegisteredResourceValueRequest) (*policy.RegisteredResourceValue, error) { + resourceID := r.GetResourceId() + value := strings.ToLower(r.GetValue()) + metadataJSON, _, err := db.MarshalCreateMetadata(r.GetMetadata()) + if err != nil { + return nil, err + } + + createdID, err := c.Queries.createRegisteredResourceValue(ctx, createRegisteredResourceValueParams{ + RegisteredResourceID: resourceID, + Value: value, + Metadata: metadataJSON, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + return c.GetRegisteredResourceValue(ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: createdID, + }, + }) +} + +func (c PolicyDBClient) GetRegisteredResourceValue(ctx context.Context, r *registeredresources.GetRegisteredResourceValueRequest) (*policy.RegisteredResourceValue, error) { + var id string + + switch { + case r.GetValueId() != "": + // TODO: refactor to pgtype.UUID once the query supports both id and fqn + id = r.GetValueId() + case r.GetFqn() != "": + // TODO: implement + return nil, errors.New("FQN support not yet implemented") + default: + // unexpected type + return nil, db.ErrSelectIdentifierInvalid + } + + rv, err := c.Queries.getRegisteredResourceValue(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + metadata := &common.Metadata{} + if err = unmarshalMetadata(rv.Metadata, metadata); err != nil { + return nil, err + } + + return &policy.RegisteredResourceValue{ + Id: rv.ID, + Value: rv.Value, + Metadata: metadata, + Resource: &policy.RegisteredResource{ + Id: rv.RegisteredResourceID, + }, + }, nil +} + +func (c PolicyDBClient) ListRegisteredResourceValues(ctx context.Context, r *registeredresources.ListRegisteredResourceValuesRequest) (*registeredresources.ListRegisteredResourceValuesResponse, error) { + resourceID := r.GetResourceId() + limit, offset := c.getRequestedLimitOffset(r.GetPagination()) + + maxLimit := c.listCfg.limitMax + if maxLimit > 0 && limit > maxLimit { + return nil, db.ErrListLimitTooLarge + } + + list, err := c.Queries.listRegisteredResourceValues(ctx, listRegisteredResourceValuesParams{ + RegisteredResourceID: resourceID, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + + rvList := make([]*policy.RegisteredResourceValue, len(list)) + + for i, r := range list { + metadata := &common.Metadata{} + if err = unmarshalMetadata(r.Metadata, metadata); err != nil { + return nil, err + } + + rvList[i] = &policy.RegisteredResourceValue{ + Id: r.ID, + Value: r.Value, + Metadata: metadata, + Resource: &policy.RegisteredResource{ + Id: r.RegisteredResourceID, + }, + } + } + + var total int32 + var nextOffset int32 + if len(list) > 0 { + total = int32(list[0].Total) + nextOffset = getNextOffset(offset, limit, total) + } + + return ®isteredresources.ListRegisteredResourceValuesResponse{ + Values: rvList, + Pagination: &policy.PageResponse{ + CurrentOffset: offset, + Total: total, + NextOffset: nextOffset, + }, + }, nil +} + +func (c PolicyDBClient) UpdateRegisteredResourceValue(ctx context.Context, r *registeredresources.UpdateRegisteredResourceValueRequest) (*policy.RegisteredResourceValue, error) { + id := r.GetId() + value := strings.ToLower(r.GetValue()) + metadataJSON, metadata, err := db.MarshalUpdateMetadata(r.GetMetadata(), r.GetMetadataUpdateBehavior(), func() (*common.Metadata, error) { + v, err := c.GetRegisteredResourceValue(ctx, ®isteredresources.GetRegisteredResourceValueRequest{ + Identifier: ®isteredresources.GetRegisteredResourceValueRequest_ValueId{ + ValueId: id, + }, + }) + if err != nil { + return nil, err + } + return v.GetMetadata(), nil + }) + if err != nil { + return nil, err + } + + count, err := c.Queries.updateRegisteredResourceValue(ctx, updateRegisteredResourceValueParams{ + ID: id, + Value: pgtypeText(value), + Metadata: metadataJSON, + }) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if count == 0 { + return nil, db.ErrNotFound + } + + return &policy.RegisteredResourceValue{ + Id: id, + Value: value, + Metadata: metadata, + }, nil +} + +func (c PolicyDBClient) DeleteRegisteredResourceValue(ctx context.Context, id string) (*policy.RegisteredResourceValue, error) { + count, err := c.Queries.deleteRegisteredResourceValue(ctx, id) + if err != nil { + return nil, db.WrapIfKnownInvalidQueryErr(err) + } + if count == 0 { + return nil, db.ErrNotFound + } + + return &policy.RegisteredResourceValue{ + Id: id, + }, nil +}