From f0c184bd1f4b8f0b56738d73db9bebbf09af3b4b Mon Sep 17 00:00:00 2001 From: SimoneDutto Date: Wed, 4 Sep 2024 16:39:46 +0200 Subject: [PATCH] Css 9402/resources (#1347) * resources endpoint --- internal/common/pagination/pagination.go | 31 +++- internal/common/pagination/pagination_test.go | 12 ++ internal/db/resource.go | 108 +++++++++++++ internal/db/resource_test.go | 95 +++++++++++ internal/jimm/resource.go | 22 +++ internal/jimm/resource_test.go | 80 ++++++++++ internal/jimmtest/jimm_mock.go | 7 + internal/jujuapi/controllerroot.go | 5 +- internal/rebac_admin/backend.go | 1 + internal/rebac_admin/export_test.go | 1 + internal/rebac_admin/resources.go | 67 ++++++++ .../rebac_admin/resources_integration_test.go | 151 ++++++++++++++++++ internal/rebac_admin/utils/utils.go | 22 +++ 13 files changed, 592 insertions(+), 10 deletions(-) create mode 100644 internal/db/resource.go create mode 100644 internal/db/resource_test.go create mode 100644 internal/jimm/resource.go create mode 100644 internal/jimm/resource_test.go create mode 100644 internal/rebac_admin/resources.go create mode 100644 internal/rebac_admin/resources_integration_test.go diff --git a/internal/common/pagination/pagination.go b/internal/common/pagination/pagination.go index 7c0360a99..90d8e9eab 100644 --- a/internal/common/pagination/pagination.go +++ b/internal/common/pagination/pagination.go @@ -44,22 +44,37 @@ func (l LimitOffsetPagination) Offset() int { } // CreatePagination returns the current page, the next page if exists, and the pagination.LimitOffsetPagination. -func CreatePagination(sizeP, pageP *int, total int) (int, *int, LimitOffsetPagination) { +func CreatePagination(sizeP, pageP *int, total int) (currentPage int, nextPage *int, _ LimitOffsetPagination) { pageSize := -1 offset := 0 - page := 0 - var nextPage *int if sizeP != nil && pageP != nil { pageSize = *sizeP - page = *pageP - offset = pageSize * page + currentPage = *pageP + offset = pageSize * currentPage } - if (page+1)*pageSize < total { - nPage := page + 1 + if (currentPage+1)*pageSize < total { + nPage := currentPage + 1 nextPage = &nPage } - return page, nextPage, NewOffsetFilter(pageSize, offset) + return currentPage, nextPage, NewOffsetFilter(pageSize, offset) +} + +// CreatePagination returns the current page, the expected page size, and the pagination.LimitOffsetPagination. +// This method is different approach to the method `CreatePagination` when we don't have the total number of records. +// We return the expectedPageSize, which is pageSize +1, so we fetch one record more from the db. +// We then check the resulting records are enough to advice the consumers to ask for one more page or not. +func CreatePaginationWithoutTotal(sizeP, pageP *int) (currentPage int, expectedPageSize int, _ LimitOffsetPagination) { + pageSize := -1 + offset := 0 + + if sizeP != nil && pageP != nil { + pageSize = *sizeP + currentPage = *pageP + offset = pageSize * currentPage + } + expectedPageSize = pageSize + 1 + return currentPage, expectedPageSize, NewOffsetFilter(pageSize+1, offset) } type OpenFGAPagination struct { diff --git a/internal/common/pagination/pagination_test.go b/internal/common/pagination/pagination_test.go index 18c3769cf..518605215 100644 --- a/internal/common/pagination/pagination_test.go +++ b/internal/common/pagination/pagination_test.go @@ -130,6 +130,18 @@ func TestCreatePagination(t *testing.T) { } } +// test the requested size is 1 more than then page size. +func TestCreatePaginationWithoutTotal(t *testing.T) { + c := qt.New(t) + pPage := utils.IntToPointer(0) + pSize := utils.IntToPointer(10) + page, size, pag := pagination.CreatePaginationWithoutTotal(pSize, pPage) + c.Assert(page, qt.Equals, 0) + c.Assert(pag.Limit(), qt.Equals, 11) + c.Assert(pag.Offset(), qt.Equals, 0) + c.Assert(size, qt.Equals, 11) +} + func TestTokenFilter(t *testing.T) { testToken := "test-token" testCases := []struct { diff --git a/internal/db/resource.go b/internal/db/resource.go new file mode 100644 index 000000000..f5dd58e10 --- /dev/null +++ b/internal/db/resource.go @@ -0,0 +1,108 @@ +// Copyright 2024 Canonical. +package db + +import ( + "context" + "database/sql" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/servermon" +) + +// RESOURCES_RAW_SQL contains the raw query fetching entities from multiple tables, with their respective entity parents. +const RESOURCES_RAW_SQL = ` +( + SELECT 'application_offer' AS type, + application_offers.uuid AS id, + application_offers.name AS name, + models.uuid AS parent_id, + models.name AS parent_name, + 'model' AS parent_type + FROM application_offers + JOIN models ON application_offers.model_id = models.id +) +UNION +( + SELECT 'cloud' AS type, + clouds.name AS id, + clouds.name AS name, + '' AS parent_id, + '' AS parent_name, + '' AS parent_type + FROM clouds +) +UNION +( + SELECT 'controller' AS type, + controllers.uuid AS id, + controllers.name AS name, + '' AS parent_id, + '' AS parent_name, + '' AS parent_type + FROM controllers +) +UNION +( + SELECT 'model' AS type, + models.uuid AS id, + models.name AS name, + controllers.uuid AS parent_id, + controllers.name AS parent_name, + 'controller' AS parent_type + FROM models + JOIN controllers ON models.controller_id = controllers.id +) +UNION +( + SELECT 'service_account' AS type, + identities.name AS id, + identities.name AS name, + '' AS parent_id, + '' AS parent_name, + '' AS parent_type + FROM identities + WHERE name LIKE '%@serviceaccount' +) +ORDER BY type, id +OFFSET ? +LIMIT ?; +` + +type Resource struct { + Type string + ID sql.NullString + Name string + ParentId sql.NullString + ParentName string + ParentType string +} + +// ListResources returns a list of models, clouds, controllers, service accounts, and application offers, with its respective parents. +// It has been implemented with a raw query because this is a specific implementation for the ReBAC Admin UI. +func (d *Database) ListResources(ctx context.Context, limit, offset int) (_ []Resource, err error) { + const op = errors.Op("db.ListResources") + if err := d.ready(); err != nil { + return nil, errors.E(op, err) + } + + durationObserver := servermon.DurationObserver(servermon.DBQueryDurationHistogram, string(op)) + defer durationObserver() + defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) + + db := d.DB.WithContext(ctx) + rows, err := db.Raw(RESOURCES_RAW_SQL, offset, limit).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + resources := make([]Resource, 0) + for rows.Next() { + var res Resource + err := db.ScanRows(rows, &res) + if err != nil { + return nil, err + } + resources = append(resources, res) + } + return resources, nil +} diff --git a/internal/db/resource_test.go b/internal/db/resource_test.go new file mode 100644 index 000000000..c42ccde1d --- /dev/null +++ b/internal/db/resource_test.go @@ -0,0 +1,95 @@ +// Copyright 2024 Canonical. +package db_test + +import ( + "context" + "database/sql" + + qt "github.com/frankban/quicktest" + "github.com/juju/juju/state" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" +) + +func SetupDB(c *qt.C, database *db.Database) (dbmodel.Model, dbmodel.Controller, dbmodel.Cloud) { + u, err := dbmodel.NewIdentity("bob@canonical.com") + c.Assert(err, qt.IsNil) + c.Assert(database.DB.Create(&u).Error, qt.IsNil) + + cloud := dbmodel.Cloud{ + Name: "test-cloud", + Type: "test-provider", + Regions: []dbmodel.CloudRegion{{ + Name: "test-region", + }}, + } + c.Assert(database.DB.Create(&cloud).Error, qt.IsNil) + + cred := dbmodel.CloudCredential{ + Name: "test-cred", + Cloud: cloud, + Owner: *u, + AuthType: "empty", + } + c.Assert(database.DB.Create(&cred).Error, qt.IsNil) + + controller := dbmodel.Controller{ + Name: "test-controller", + UUID: "00000000-0000-0000-0000-0000-0000000000001", + CloudName: "test-cloud", + CloudRegion: "test-region", + } + err = database.AddController(context.Background(), &controller) + c.Assert(err, qt.Equals, nil) + + model := dbmodel.Model{ + Name: "test-model-1", + UUID: sql.NullString{ + String: "00000001-0000-0000-0000-0000-000000000001", + Valid: true, + }, + OwnerIdentityName: u.Name, + ControllerID: controller.ID, + CloudRegionID: cloud.Regions[0].ID, + CloudCredentialID: cred.ID, + Type: "iaas", + DefaultSeries: "warty", + Life: state.Alive.String(), + Status: dbmodel.Status{ + Status: "available", + Since: db.Now(), + }, + SLA: dbmodel.SLA{ + Level: "unsupported", + }, + } + err = database.AddModel(context.Background(), &model) + c.Assert(err, qt.Equals, nil) + return model, controller, cloud +} + +func (s *dbSuite) TestGetResources(c *qt.C) { + ctx := context.Background() + err := s.Database.Migrate(context.Background(), true) + c.Assert(err, qt.Equals, nil) + res, err := s.Database.ListResources(ctx, 10, 0) + c.Assert(err, qt.Equals, nil) + c.Assert(res, qt.HasLen, 0) + // create one model, one controller, one cloud + model, controller, cloud := SetupDB(c, s.Database) + res, err = s.Database.ListResources(ctx, 10, 0) + c.Assert(err, qt.Equals, nil) + c.Assert(res, qt.HasLen, 3) + for _, r := range res { + switch r.Type { + case "model": + c.Assert(r.ID.String, qt.Equals, model.UUID.String) + c.Assert(r.ParentId.String, qt.Equals, controller.UUID) + case "controller": + c.Assert(r.ID.String, qt.Equals, controller.UUID) + case "cloud": + c.Assert(r.ID.String, qt.Equals, cloud.Name) + } + } +} diff --git a/internal/jimm/resource.go b/internal/jimm/resource.go new file mode 100644 index 000000000..134ba56ed --- /dev/null +++ b/internal/jimm/resource.go @@ -0,0 +1,22 @@ +// Copyright 2024 Canonical. +package jimm + +import ( + "context" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// ListResources returns a list of resources known to JIMM with a pagination filter. +func (j *JIMM) ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) { + const op = errors.Op("jimm.ListResources") + + if !user.JimmAdmin { + return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + return j.Database.ListResources(ctx, filter.Limit(), filter.Offset()) +} diff --git a/internal/jimm/resource_test.go b/internal/jimm/resource_test.go new file mode 100644 index 000000000..a6c78d2ea --- /dev/null +++ b/internal/jimm/resource_test.go @@ -0,0 +1,80 @@ +// Copyright 2024 Canonical. +package jimm_test + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimm" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/openfga" +) + +func TestGetResources(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + _, _, controller, model, applicationOffer, cloud, _ := createTestControllerEnvironment(ctx, c, j.Database) + + ids := []string{applicationOffer.UUID, cloud.Name, controller.UUID, model.UUID.String} + + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, ofgaClient) + u.JimmAdmin = true + + testCases := []struct { + desc string + limit int + offset int + identities []string + }{ + { + desc: "test with first resources", + limit: 3, + offset: 0, + identities: []string{ids[0], ids[1], ids[2]}, + }, + { + desc: "test with remianing ids", + limit: 3, + offset: 3, + identities: []string{ids[3]}, + }, + { + desc: "test out of range", + limit: 3, + offset: 6, + identities: []string{}, + }, + } + for _, t := range testCases { + c.Run(t.desc, func(c *qt.C) { + filter := pagination.NewOffsetFilter(t.limit, t.offset) + resources, err := j.ListResources(ctx, u, filter) + c.Assert(err, qt.IsNil) + c.Assert(resources, qt.HasLen, len(t.identities)) + for i := range len(t.identities) { + c.Assert(resources[i].ID.String, qt.Equals, t.identities[i]) + } + }) + } +} diff --git a/internal/jimmtest/jimm_mock.go b/internal/jimmtest/jimm_mock.go index 75f3daded..de846f8f0 100644 --- a/internal/jimmtest/jimm_mock.go +++ b/internal/jimmtest/jimm_mock.go @@ -68,6 +68,7 @@ type JIMM struct { InitiateMigration_ func(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) InitiateInternalMigration_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetController string) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) + ListResources_ func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) Offer_ func(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error PubSubHub_ func() *pubsub.Hub PurgeLogs_ func(ctx context.Context, user *openfga.User, before time.Time) (int64, error) @@ -300,6 +301,12 @@ func (j *JIMM) ListApplicationOffers(ctx context.Context, user *openfga.User, fi } return j.ListApplicationOffers_(ctx, user, filters...) } +func (j *JIMM) ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) { + if j.ListResources_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.ListResources_(ctx, user, filter) +} func (j *JIMM) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error { if j.Offer_ == nil { return errors.E(errors.CodeNotImplemented) diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index 82272e8cc..9406570b6 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -38,6 +38,7 @@ type JIMM interface { AddHostedCloud(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddServiceAccount(ctx context.Context, u *openfga.User, clientId string) error CopyServiceAccountCredential(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) + CountIdentities(ctx context.Context, user *openfga.User) (int, error) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error FindApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) @@ -51,10 +52,8 @@ type JIMM interface { GetCloudCredentialAttributes(ctx context.Context, u *openfga.User, cred *dbmodel.CloudCredential, hidden bool) (attrs map[string]string, redacted []string, err error) GetCredentialStore() credentials.CredentialStore GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) - ListIdentities(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) // FetchIdentity finds the user in jimm or returns a not-found error FetchIdentity(ctx context.Context, username string) (*openfga.User, error) - CountIdentities(ctx context.Context, user *openfga.User) (int, error) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) GetUserControllerAccess(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) GetUserModelAccess(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) @@ -66,6 +65,8 @@ type JIMM interface { InitiateInternalMigration(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetController string) (jujuparams.InitiateMigrationResult, error) InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) + ListIdentities(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) + ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error PubSubHub() *pubsub.Hub PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) diff --git a/internal/rebac_admin/backend.go b/internal/rebac_admin/backend.go index 0af81f3b1..b021ff9ce 100644 --- a/internal/rebac_admin/backend.go +++ b/internal/rebac_admin/backend.go @@ -21,6 +21,7 @@ func SetupBackend(ctx context.Context, jimm jujuapi.JIMM) (*rebac_handlers.ReBAC Entitlements: newEntitlementService(), Groups: newGroupService(jimm), Identities: newidentitiesService(jimm), + Resources: newResourcesService(jimm), }) if err != nil { zapctx.Error(ctx, "failed to create rebac admin backend", zap.Error(err)) diff --git a/internal/rebac_admin/export_test.go b/internal/rebac_admin/export_test.go index 953b447e0..b59df4943 100644 --- a/internal/rebac_admin/export_test.go +++ b/internal/rebac_admin/export_test.go @@ -4,6 +4,7 @@ package rebac_admin var ( NewGroupService = newGroupService NewidentitiesService = newidentitiesService + NewResourcesService = newResourcesService ) type GroupsService = groupsService diff --git a/internal/rebac_admin/resources.go b/internal/rebac_admin/resources.go new file mode 100644 index 000000000..baca7aa19 --- /dev/null +++ b/internal/rebac_admin/resources.go @@ -0,0 +1,67 @@ +// Copyright 2024 Canonical. + +package rebac_admin + +import ( + "context" + + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/jujuapi" + "github.com/canonical/jimm/v3/internal/rebac_admin/utils" +) + +type resourcesService struct { + jimm jujuapi.JIMM +} + +func newResourcesService(jimm jujuapi.JIMM) *resourcesService { + return &resourcesService{ + jimm: jimm, + } +} + +// ListResources returns a page of Resource objects of at least `size` elements if available. +func (s *resourcesService) ListResources(ctx context.Context, params *resources.GetResourcesParams) (*resources.PaginatedResponse[resources.Resource], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + currentPage, expectedPageSize, pagination := pagination.CreatePaginationWithoutTotal(params.Size, params.Page) + res, err := s.jimm.ListResources(ctx, user, pagination) + if err != nil { + return nil, err + } + nextPage, res := getNextPageAndResources(currentPage, expectedPageSize, res) + rRes := make([]resources.Resource, len(res)) + for i, u := range res { + rRes[i] = utils.ToRebacResource(u) + } + + return &resources.PaginatedResponse[resources.Resource]{ + Data: rRes, + Meta: resources.ResponseMeta{ + Page: ¤tPage, + Size: len(rRes), + Total: nil, + }, + Next: resources.Next{ + Page: nextPage, + }, + }, nil +} + +// getNextPageAndResources checks for the expectedPageSize of the resources. +// If there is enough records we return the records minus 1 and advice the consumer there is another page. +// Otherwise we return the records we have and set next page as empty. +func getNextPageAndResources(currentPage, expectedPageSize int, resources []db.Resource) (*int, []db.Resource) { + var nextPage *int + if len(resources) == expectedPageSize { + nPage := currentPage + 1 + nextPage = &nPage + resources = resources[:len(resources)-1] + } + return nextPage, resources +} diff --git a/internal/rebac_admin/resources_integration_test.go b/internal/rebac_admin/resources_integration_test.go new file mode 100644 index 000000000..dbb8bc352 --- /dev/null +++ b/internal/rebac_admin/resources_integration_test.go @@ -0,0 +1,151 @@ +// Copyright 2024 Canonical. +package rebac_admin_test + +import ( + "context" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + gc "gopkg.in/check.v1" + + "github.com/canonical/jimm/v3/internal/common/utils" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/rebac_admin" +) + +type resourcesSuite struct { + jimmtest.JIMMSuite +} + +var _ = gc.Suite(&resourcesSuite{}) + +// resourcesTestEnv is used to create entries in JIMM's database. +// The rebacAdminSuite does not spin up a Juju controller so we cannot use +// regular JIMM methods to create resources. +const resourcesTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-cloud-region +cloud-credentials: +- owner: alice@canonical.com + name: cred-1 + cloud: test-cloud +controllers: +- name: controller-1 + uuid: 00000001-0000-0000-0000-000000000001 + cloud: test-cloud + region: test-cloud-region +models: +- name: model-1 + uuid: 00000002-0000-0000-0000-000000000001 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-2 + uuid: 00000002-0000-0000-0000-000000000002 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-3 + uuid: 00000003-0000-0000-0000-000000000003 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +` + +func (s *resourcesSuite) TestListResources(c *gc.C) { + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + resourcesSvc := rebac_admin.NewResourcesService(s.JIMM) + tester := jimmtest.GocheckTester{C: c} + env := jimmtest.ParseEnvironment(tester, resourcesTestEnv) + env.PopulateDB(tester, s.JIMM.Database) + type testEntity struct { + Id string + ParentId string + } + ids := make([]testEntity, 0) + for _, c := range env.Clouds { + ids = append(ids, testEntity{ + Id: c.Name, + ParentId: "", + }) + } + for _, c := range env.Controllers { + ids = append(ids, testEntity{ + Id: c.UUID, + ParentId: "", + }) + } + for _, m := range env.Models { + ids = append(ids, testEntity{ + Id: m.UUID, + ParentId: env.Controller(m.Controller).UUID, + }) + } + + testCases := []struct { + desc string + size *int + page *int + wantPage int + wantSize int + wantNextpage *int + ids []testEntity + }{ + { + desc: "test with first page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(0), + wantPage: 0, + wantSize: 2, + wantNextpage: utils.IntToPointer(1), + ids: []testEntity{ids[0], ids[1]}, + }, + { + desc: "test with second page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(1), + wantPage: 1, + wantSize: 2, + wantNextpage: utils.IntToPointer(2), + ids: []testEntity{ids[2], ids[3]}, + }, + { + desc: "test with last page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(2), + wantPage: 2, + wantSize: 1, + wantNextpage: nil, + ids: []testEntity{ids[4]}, + }, + } + for _, t := range testCases { + resources, err := resourcesSvc.ListResources(ctx, &resources.GetResourcesParams{ + Size: t.size, + Page: t.page, + }) + c.Assert(err, gc.IsNil) + c.Assert(*resources.Meta.Page, gc.Equals, t.wantPage) + c.Assert(resources.Meta.Size, gc.Equals, t.wantSize) + if t.wantNextpage == nil { + c.Assert(resources.Next.Page, gc.IsNil) + } else { + c.Assert(*resources.Next.Page, gc.Equals, *t.wantNextpage) + } + for i := range len(t.ids) { + c.Assert(resources.Data[i].Entity.Id, gc.Equals, t.ids[i].Id) + if t.ids[i].ParentId != "" { + c.Assert(resources.Data[i].Parent.Id, gc.Equals, t.ids[i].ParentId) + } + } + } +} diff --git a/internal/rebac_admin/utils/utils.go b/internal/rebac_admin/utils/utils.go index 11bde5059..3908b3d40 100644 --- a/internal/rebac_admin/utils/utils.go +++ b/internal/rebac_admin/utils/utils.go @@ -10,6 +10,7 @@ import ( "github.com/juju/names/v5" "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/openfga" jimmnames "github.com/canonical/jimm/v3/pkg/names" ) @@ -28,6 +29,27 @@ func FromUserToIdentity(user openfga.User) resources.Identity { } } +// ToRebacResource parses db.Resource into resources.Resource. +func ToRebacResource(res db.Resource) resources.Resource { + r := resources.Resource{ + Entity: resources.Entity{ + Id: res.ID.String, + Name: res.Name, + Type: res.Type, + }, + } + // the parent is populated only for models and application offers. + // the parent type is set empty from the query. + if res.ParentType != "" { + r.Parent = &resources.Entity{ + Id: res.ParentId.String, + Name: res.ParentName, + Type: res.ParentType, + } + } + return r +} + // CreateTokenPaginationFilter returns a token pagination filter based on the rebac admin request parameters. func CreateTokenPaginationFilter(size *int, token, tokenFromHeader *string) pagination.OpenFGAPagination { pageSize := 0