diff --git a/internal/common/pagination/pagination.go b/internal/common/pagination/pagination.go index 7c0360a99..6e7840ac8 100644 --- a/internal/common/pagination/pagination.go +++ b/internal/common/pagination/pagination.go @@ -62,6 +62,22 @@ func CreatePagination(sizeP, pageP *int, total int) (int, *int, LimitOffsetPagin return page, nextPage, NewOffsetFilter(pageSize, offset) } +// CreatePagination returns the current page, the 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. +func CreatePaginationWithoutTotal(sizeP, pageP *int) (int, int, LimitOffsetPagination) { + pageSize := -1 + offset := 0 + page := 0 + + if sizeP != nil && pageP != nil { + pageSize = *sizeP + page = *pageP + offset = pageSize * page + } + + return page, pageSize + 1, NewOffsetFilter(pageSize+1, offset) +} + type OpenFGAPagination struct { limit int token string diff --git a/internal/db/resources.go b/internal/db/resources.go index f825a0afb..68843bbef 100644 --- a/internal/db/resources.go +++ b/internal/db/resources.go @@ -9,11 +9,12 @@ import ( "github.com/canonical/jimm/v3/internal/servermon" ) -const MUTIPLE_PAGE_SQL = ` +// MULTI_TABLES_RAW_SQL contains the raw query fetching entities from multiple tables, with their respective entity parents. +const MULTI_TABLES_RAW_SQL = ` ( SELECT 'controller' AS type, - uuid AS id, - name AS name, + controllers.uuid AS id, + controllers.name AS name, '' AS parent_id, '' AS parent_name, '' AS parent_type @@ -69,14 +70,16 @@ LIMIT ?; type Resource struct { Type string - UUID sql.NullString + ID sql.NullString Name string ParentId sql.NullString ParentName string ParentType string } -func (d *Database) GetResources(ctx context.Context, limit, offset int) (_ []Resource, err error) { +// 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.GetMultipleModels") if err := d.ready(); err != nil { return nil, errors.E(op, err) @@ -87,7 +90,7 @@ func (d *Database) GetResources(ctx context.Context, limit, offset int) (_ []Res defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) db := d.DB.WithContext(ctx) - rows, err := db.Raw(MUTIPLE_PAGE_SQL, offset, limit).Rows() + rows, err := db.Raw(MULTI_TABLES_RAW_SQL, offset, limit).Rows() if err != nil { return nil, err } diff --git a/internal/db/resources_test.go b/internal/db/resources_test.go index 7606c8f06..2fd228658 100644 --- a/internal/db/resources_test.go +++ b/internal/db/resources_test.go @@ -12,7 +12,7 @@ import ( "github.com/canonical/jimm/v3/internal/dbmodel" ) -func (s *dbSuite) Setup(c *qt.C) { +func (s *dbSuite) Setup(c *qt.C) (dbmodel.Model, dbmodel.Controller, dbmodel.Cloud) { err := s.Database.Migrate(context.Background(), true) c.Assert(err, qt.Equals, nil) @@ -69,13 +69,25 @@ func (s *dbSuite) Setup(c *qt.C) { } err = s.Database.AddModel(context.Background(), &model) c.Assert(err, qt.Equals, nil) + return model, controller, cloud } func (s *dbSuite) TestGetResources(c *qt.C) { // create one model, one controller, one cloud - s.Setup(c) + model, controller, cloud := s.Setup(c) ctx := context.Background() - res, err := s.Database.GetResources(ctx, 10, 0) + 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..fe013b97a --- /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.GetResources") + + 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..5e75b01ec --- /dev/null +++ b/internal/jimm/resource_test.go @@ -0,0 +1,82 @@ +// Copyright 2024 Canonical. +package jimm_test + +import ( + "context" + "slices" + "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) + user, _, controller, model, applicationOffer, cloud, _ := createTestControllerEnvironment(ctx, c, j.Database) + + ids := []string{user.Name, controller.UUID, model.UUID.String, applicationOffer.UUID, cloud.Name} + slices.Sort(ids) + + 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], ids[4]}, + }, + { + 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/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 index cce36921c..b4c7790dc 100644 --- a/internal/rebac_admin/resources.go +++ b/internal/rebac_admin/resources.go @@ -7,11 +7,11 @@ import ( "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/canonical/jimm/v3/internal/common/pagination" "github.com/canonical/jimm/v3/internal/jujuapi" "github.com/canonical/jimm/v3/internal/rebac_admin/utils" ) -// resourcesService implements the `resourcesService` interface. type resourcesService struct { jimm jujuapi.JIMM } @@ -22,11 +22,39 @@ func newResourcesService(jimm jujuapi.JIMM) *resourcesService { } } -// resourcesService defines an abstract backend to handle Resources related operations. +// 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) { - _, err := utils.GetUserFromContext(ctx) + user, err := utils.GetUserFromContext(ctx) if err != nil { return nil, err } - return nil, nil + page, pageSize, pagination := pagination.CreatePaginationWithoutTotal(params.Size, params.Page) + res, err := s.jimm.ListResources(ctx, user, pagination) + if err != nil { + return nil, err + } + // We fetch one record more than the page size. Then, we set the next page if we have this many records. + // Otherwise next page is empty. + var nextPage *int + if len(res) == pageSize { + nPage := page + 1 + nextPage = &nPage + res = res[:len(res)-1] + } + rRes := make([]resources.Resource, len(res)) + for i, u := range res { + rRes[i] = utils.FromDbResourcesToResources(u) + } + + return &resources.PaginatedResponse[resources.Resource]{ + Data: rRes, + Meta: resources.ResponseMeta{ + Page: &page, + Size: len(rRes), + Total: nil, + }, + Next: resources.Next{ + Page: nextPage, + }, + }, nil } diff --git a/internal/rebac_admin/resources_integration_test.go b/internal/rebac_admin/resources_integration_test.go new file mode 100644 index 000000000..7110bbf63 --- /dev/null +++ b/internal/rebac_admin/resources_integration_test.go @@ -0,0 +1,145 @@ +// Copyright 2024 Canonical. +package rebac_admin_test + +import ( + "context" + "slices" + + 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{}) + +// patchIdentitiesEntitlementTestEnv 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. It is also necessary to have resources +// present in the database in order for ListRelationshipTuples to work correctly. +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 +` + +// TestPatchIdentityEntitlements tests the patching of entitlements for a specific identityId, +// adding and removing relations after the setup. +// Setup: add user to a group, and add models to the user. +func (s *resourcesSuite) TestPatchIdentityEntitlements(c *gc.C) { + // initialization + 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) + ids := make([]string, 0) + for _, m := range env.Models { + ids = append(ids, m.UUID) + } + for _, c := range env.Controllers { + ids = append(ids, c.UUID) + } + for _, c := range env.Clouds { + ids = append(ids, c.Name) + } + + slices.Sort(ids) + + testCases := []struct { + desc string + size *int + page *int + wantPage int + wantSize int + wantNextpage *int + ids []string + }{ + { + desc: "test with first page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(0), + wantPage: 0, + wantSize: 2, + wantNextpage: utils.IntToPointer(1), + ids: []string{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: []string{ids[2], ids[3]}, + }, + { + desc: "test with last page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(2), + wantPage: 2, + wantSize: 1, + wantNextpage: nil, + ids: []string{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]) + } + } + +} diff --git a/internal/rebac_admin/utils/utils.go b/internal/rebac_admin/utils/utils.go index 11bde5059..e73c794cf 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 { } } +// FromUserToIdentity parses openfga.User into resources.Identity . +func FromDbResourcesToResources(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