diff --git a/internal/common/utils/test_utils.go b/internal/common/utils/test_utils.go index 4e0b7fdcc..dc63a2b27 100644 --- a/internal/common/utils/test_utils.go +++ b/internal/common/utils/test_utils.go @@ -4,3 +4,7 @@ package utils func IntToPointer(i int) *int { return &i } + +func StringToPointer(s string) *string { + return &s +} diff --git a/internal/db/resource.go b/internal/db/resource.go index f5dd58e10..7eaab7749 100644 --- a/internal/db/resource.go +++ b/internal/db/resource.go @@ -5,64 +5,65 @@ import ( "context" "database/sql" + "gorm.io/gorm" + + "github.com/canonical/jimm/v3/internal/dbmodel" "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' -) +const ApplicationOffersQueryKey = "application_offers" +const selectApplicationOffers = ` +'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 +` + +const CloudsQueryKey = "clouds" +const selectClouds = ` +'cloud' AS type, +clouds.name AS id, +clouds.name AS name, +'' AS parent_id, +'' AS parent_name, +'' AS parent_type +` + +const ControllersQueryKey = "controllers" +const selectControllers = ` +'controller' AS type, +controllers.uuid AS id, +controllers.name AS name, +'' AS parent_id, +'' AS parent_name, +'' AS parent_type +` + +const ModelsQueryKey = "models" +const selectModels = ` +'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 +` + +const ServiceAccountQueryKey = "identities" +const selectIdentities = ` +'service_account' AS type, +identities.name AS id, +identities.name AS name, +'' AS parent_id, +'' AS parent_name, +'' AS parent_type +` + +const unionQuery = ` +? UNION ? UNION ? UNION ? UNION ? ORDER BY type, id OFFSET ? LIMIT ?; @@ -79,7 +80,7 @@ type Resource struct { // 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) { +func (d *Database) ListResources(ctx context.Context, limit, offset int, namePrefixFilter, typeFilter string) (_ []Resource, err error) { const op = errors.Op("db.ListResources") if err := d.ready(); err != nil { return nil, errors.E(op, err) @@ -90,7 +91,11 @@ func (d *Database) ListResources(ctx context.Context, limit, offset int) (_ []Re defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) db := d.DB.WithContext(ctx) - rows, err := db.Raw(RESOURCES_RAW_SQL, offset, limit).Rows() + query, err := buildQuery(db, offset, limit, namePrefixFilter, typeFilter) + if err != nil { + return nil, err + } + rows, err := query.Rows() if err != nil { return nil, err } @@ -106,3 +111,61 @@ func (d *Database) ListResources(ctx context.Context, limit, offset int) (_ []Re } return resources, nil } + +// buildQuery is a utility function to build the database query according to two optional parameters. +// namePrefixFilter: used to match resources name prefix. +// typeFilter: used to match resources type. If this is not empty the resources are fetched from a single table. +func buildQuery(db *gorm.DB, offset, limit int, namePrefixFilter, typeFilter string) (*gorm.DB, error) { + applicationOffersQuery := db.Select(selectApplicationOffers). + Model(&dbmodel.ApplicationOffer{}). + Where("(CASE WHEN ? = '' THEN TRUE ELSE application_offers.name LIKE ? END)", namePrefixFilter, namePrefixFilter+"%"). + Joins("JOIN models ON application_offers.model_id = models.id") + + cloudsQuery := db.Select(selectClouds). + Model(&dbmodel.Cloud{}). + Where("(CASE WHEN ? = '' THEN TRUE ELSE clouds.name LIKE ? END)", namePrefixFilter, namePrefixFilter+"%") + + controllersQuery := db.Select(selectControllers). + Model(&dbmodel.Controller{}). + Where("(CASE WHEN ? = '' THEN TRUE ELSE controllers.name LIKE ? END)", namePrefixFilter, namePrefixFilter+"%") + + modelsQuery := db.Select(selectModels). + Model(&dbmodel.Model{}). + Where("(CASE WHEN ? = '' THEN TRUE ELSE models.name LIKE ? END)", namePrefixFilter, namePrefixFilter+"%"). + Joins("JOIN controllers ON models.controller_id = controllers.id") + + serviceAccountsQuery := db.Select(selectIdentities). + Model(&dbmodel.Identity{}). + Where("name LIKE '%@serviceaccount' AND (CASE WHEN ? = '' THEN TRUE ELSE identities.name LIKE ? END)", namePrefixFilter, namePrefixFilter+"%") + + // if the typeFilter is set we only return the query for that specif entityType, otherwise the union. + if typeFilter == "" { + return db. + Raw(unionQuery, + applicationOffersQuery, + cloudsQuery, + controllersQuery, + modelsQuery, + serviceAccountsQuery, + offset, + limit, + ), nil + } + var query *gorm.DB + switch typeFilter { + case ControllersQueryKey: + query = controllersQuery + case CloudsQueryKey: + query = cloudsQuery + case ApplicationOffersQueryKey: + query = applicationOffersQuery + case ModelsQueryKey: + query = modelsQuery + case ServiceAccountQueryKey: + query = serviceAccountsQuery + default: + // this shouldn't happen because we have validated the entityFilter at API layer + return nil, errors.E("this entityType does not exist") + } + return query.Order("id").Offset(offset).Limit(limit), nil +} diff --git a/internal/db/resource_test.go b/internal/db/resource_test.go index c42ccde1d..6786e5cbf 100644 --- a/internal/db/resource_test.go +++ b/internal/db/resource_test.go @@ -12,7 +12,7 @@ import ( "github.com/canonical/jimm/v3/internal/dbmodel" ) -func SetupDB(c *qt.C, database *db.Database) (dbmodel.Model, dbmodel.Controller, dbmodel.Cloud) { +func SetupDB(c *qt.C, database *db.Database) (dbmodel.Model, dbmodel.Controller, dbmodel.Cloud, dbmodel.Identity) { u, err := dbmodel.NewIdentity("bob@canonical.com") c.Assert(err, qt.IsNil) c.Assert(database.DB.Create(&u).Error, qt.IsNil) @@ -66,21 +66,27 @@ func SetupDB(c *qt.C, database *db.Database) (dbmodel.Model, dbmodel.Controller, } err = database.AddModel(context.Background(), &model) c.Assert(err, qt.Equals, nil) - return model, controller, cloud + clientIDWithDomain := "abda51b2-d735-4794-a8bd-49c506baa4af@serviceaccount" + sa, err := dbmodel.NewIdentity(clientIDWithDomain) + c.Assert(err, qt.Equals, nil) + err = database.GetIdentity(context.Background(), sa) + c.Assert(err, qt.Equals, nil) + + return model, controller, cloud, *sa } 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) + 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) + model, controller, cloud, sva := SetupDB(c, s.Database) + res, err = s.Database.ListResources(ctx, 10, 0, "", "") c.Assert(err, qt.Equals, nil) - c.Assert(res, qt.HasLen, 3) + c.Assert(res, qt.HasLen, 4) for _, r := range res { switch r.Type { case "model": @@ -90,6 +96,91 @@ func (s *dbSuite) TestGetResources(c *qt.C) { c.Assert(r.ID.String, qt.Equals, controller.UUID) case "cloud": c.Assert(r.ID.String, qt.Equals, cloud.Name) + case "service_account": + c.Assert(r.ID.String, qt.Equals, sva.Name) } } } + +func (s *dbSuite) TestGetResourcesWithNameTypeFilter(c *qt.C) { + ctx := context.Background() + err := s.Database.Migrate(context.Background(), true) + c.Assert(err, qt.Equals, nil) + // create one model, one controller, one cloud + model, controller, cloud, sva := SetupDB(c, s.Database) + + tests := []struct { + description string + nameFilter string + typeFilter string + limit int + offset int + expectedSize int + expectedUUIDs []string + }{ + { + description: "filter on model name", + nameFilter: model.Name, + limit: 10, + offset: 0, + typeFilter: "", + expectedSize: 1, + expectedUUIDs: []string{model.UUID.String}, + }, + { + description: "filter name test prefix", + nameFilter: "test", + limit: 10, + offset: 0, + typeFilter: "", + expectedSize: 3, + expectedUUIDs: []string{cloud.Name, controller.UUID, model.UUID.String}, + }, + { + description: "filter name controller suffix", + nameFilter: "controller", + limit: 10, + offset: 0, + typeFilter: "", + expectedSize: 0, + expectedUUIDs: []string{}, + }, + { + description: "filter only models", + nameFilter: "test", + limit: 10, + offset: 0, + typeFilter: "models", + expectedSize: 1, + expectedUUIDs: []string{model.UUID.String}, + }, + { + description: "filter only service accounts", + nameFilter: "", + limit: 10, + offset: 0, + typeFilter: "identities", + expectedSize: 1, + expectedUUIDs: []string{sva.Name}, + }, + { + description: "filter only service accounts and name", + nameFilter: "not-found", + limit: 10, + offset: 0, + typeFilter: "identities", + expectedSize: 0, + expectedUUIDs: []string{}, + }, + } + for _, t := range tests { + c.Run(t.description, func(c *qt.C) { + res, err := s.Database.ListResources(ctx, t.limit, t.offset, t.nameFilter, t.typeFilter) + c.Assert(err, qt.Equals, nil) + c.Assert(res, qt.HasLen, t.expectedSize) + for i, r := range res { + c.Assert(r.ID.String, qt.Equals, t.expectedUUIDs[i]) + } + }) + } +} diff --git a/internal/jimm/resource.go b/internal/jimm/resource.go index 134ba56ed..c1734a551 100644 --- a/internal/jimm/resource.go +++ b/internal/jimm/resource.go @@ -11,12 +11,12 @@ import ( ) // 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) { +func (j *JIMM) ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, namePrefixFilter, typeFilter string) ([]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()) + return j.Database.ListResources(ctx, filter.Limit(), filter.Offset(), namePrefixFilter, typeFilter) } diff --git a/internal/jimm/resource_test.go b/internal/jimm/resource_test.go index a6c78d2ea..f03da279f 100644 --- a/internal/jimm/resource_test.go +++ b/internal/jimm/resource_test.go @@ -69,7 +69,7 @@ func TestGetResources(t *testing.T) { 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) + 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) { diff --git a/internal/jimmtest/jimm_mock.go b/internal/jimmtest/jimm_mock.go index de846f8f0..7371d4c45 100644 --- a/internal/jimmtest/jimm_mock.go +++ b/internal/jimmtest/jimm_mock.go @@ -68,7 +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) + ListResources_ func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, namePrefixFilter, typeFilter string) ([]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) @@ -301,11 +301,11 @@ 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) { +func (j *JIMM) ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, namePrefixFilter, typeFilter string) ([]db.Resource, error) { if j.ListResources_ == nil { return nil, errors.E(errors.CodeNotImplemented) } - return j.ListResources_(ctx, user, filter) + return j.ListResources_(ctx, user, filter, namePrefixFilter, typeFilter) } func (j *JIMM) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error { if j.Offer_ == nil { diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index 9406570b6..19cb01507 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -66,7 +66,7 @@ type JIMM interface { 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) + ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, namePrefixFilter, typeFilter string) ([]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/entitlements.go b/internal/rebac_admin/entitlements.go index 9f0d930a6..073ec18c2 100644 --- a/internal/rebac_admin/entitlements.go +++ b/internal/rebac_admin/entitlements.go @@ -10,56 +10,63 @@ import ( openfgastatic "github.com/canonical/jimm/v3/openfga" ) +const ApplicationOffer = "applicationoffer" +const Cloud = "cloud" +const Controller = "controller" +const Group = "group" +const Model = "model" +const ServiceAccount = "serviceaccount" + // For rebac v1 this list is kept manually. // The reason behind that is we want to decide what relations to expose to rebac admin ui. var EntitlementsList = []resources.EntitlementSchema{ // applicationoffer - {Entitlement: "administrator", ReceiverType: "user", EntityType: "applicationoffer"}, - {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "applicationoffer"}, - {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "applicationoffer"}, - {Entitlement: "consumer", ReceiverType: "user", EntityType: "applicationoffer"}, - {Entitlement: "consumer", ReceiverType: "user:*", EntityType: "applicationoffer"}, - {Entitlement: "consumer", ReceiverType: "group#member", EntityType: "applicationoffer"}, - {Entitlement: "reader", ReceiverType: "user", EntityType: "applicationoffer"}, - {Entitlement: "reader", ReceiverType: "user:*", EntityType: "applicationoffer"}, - {Entitlement: "reader", ReceiverType: "group#member", EntityType: "applicationoffer"}, + {Entitlement: "administrator", ReceiverType: "user", EntityType: ApplicationOffer}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: ApplicationOffer}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: ApplicationOffer}, + {Entitlement: "consumer", ReceiverType: "user", EntityType: ApplicationOffer}, + {Entitlement: "consumer", ReceiverType: "user:*", EntityType: ApplicationOffer}, + {Entitlement: "consumer", ReceiverType: "group#member", EntityType: ApplicationOffer}, + {Entitlement: "reader", ReceiverType: "user", EntityType: ApplicationOffer}, + {Entitlement: "reader", ReceiverType: "user:*", EntityType: ApplicationOffer}, + {Entitlement: "reader", ReceiverType: "group#member", EntityType: ApplicationOffer}, // cloud - {Entitlement: "administrator", ReceiverType: "user", EntityType: "cloud"}, - {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "cloud"}, - {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "cloud"}, - {Entitlement: "can_addmodel", ReceiverType: "user", EntityType: "cloud"}, - {Entitlement: "can_addmodel", ReceiverType: "user:*", EntityType: "cloud"}, - {Entitlement: "can_addmodel", ReceiverType: "group#member", EntityType: "cloud"}, + {Entitlement: "administrator", ReceiverType: "user", EntityType: Cloud}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: Cloud}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: Cloud}, + {Entitlement: "can_addmodel", ReceiverType: "user", EntityType: Cloud}, + {Entitlement: "can_addmodel", ReceiverType: "user:*", EntityType: Cloud}, + {Entitlement: "can_addmodel", ReceiverType: "group#member", EntityType: Cloud}, // controller - {Entitlement: "administrator", ReceiverType: "user", EntityType: "controller"}, - {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "controller"}, - {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "controller"}, - {Entitlement: "audit_log_viewer", ReceiverType: "user", EntityType: "controller"}, - {Entitlement: "audit_log_viewer", ReceiverType: "user:*", EntityType: "controller"}, - {Entitlement: "audit_log_viewer", ReceiverType: "group#member", EntityType: "controller"}, + {Entitlement: "administrator", ReceiverType: "user", EntityType: Controller}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: Controller}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: Controller}, + {Entitlement: "audit_log_viewer", ReceiverType: "user", EntityType: Controller}, + {Entitlement: "audit_log_viewer", ReceiverType: "user:*", EntityType: Controller}, + {Entitlement: "audit_log_viewer", ReceiverType: "group#member", EntityType: Controller}, // group - {Entitlement: "member", ReceiverType: "user", EntityType: "group"}, - {Entitlement: "member", ReceiverType: "user:*", EntityType: "group"}, - {Entitlement: "member", ReceiverType: "group#member", EntityType: "group"}, + {Entitlement: "member", ReceiverType: "user", EntityType: Group}, + {Entitlement: "member", ReceiverType: "user:*", EntityType: Group}, + {Entitlement: "member", ReceiverType: "group#member", EntityType: Group}, // model - {Entitlement: "administrator", ReceiverType: "user", EntityType: "model"}, - {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "model"}, - {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "model"}, - {Entitlement: "reader", ReceiverType: "user", EntityType: "model"}, - {Entitlement: "reader", ReceiverType: "user:*", EntityType: "model"}, - {Entitlement: "reader", ReceiverType: "group#member", EntityType: "model"}, - {Entitlement: "writer", ReceiverType: "user", EntityType: "model"}, - {Entitlement: "writer", ReceiverType: "user:*", EntityType: "model"}, - {Entitlement: "writer", ReceiverType: "group#member", EntityType: "model"}, + {Entitlement: "administrator", ReceiverType: "user", EntityType: Model}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: Model}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: Model}, + {Entitlement: "reader", ReceiverType: "user", EntityType: Model}, + {Entitlement: "reader", ReceiverType: "user:*", EntityType: Model}, + {Entitlement: "reader", ReceiverType: "group#member", EntityType: Model}, + {Entitlement: "writer", ReceiverType: "user", EntityType: Model}, + {Entitlement: "writer", ReceiverType: "user:*", EntityType: Model}, + {Entitlement: "writer", ReceiverType: "group#member", EntityType: Model}, // serviceaccount - {Entitlement: "administrator", ReceiverType: "user", EntityType: "serviceaccount"}, - {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "serviceaccount"}, - {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "serviceaccount"}, + {Entitlement: "administrator", ReceiverType: "user", EntityType: ServiceAccount}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: ServiceAccount}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: ServiceAccount}, } // entitlementsService implements the `entitlementsService` interface from rebac-admin-ui-handlers library diff --git a/internal/rebac_admin/resources.go b/internal/rebac_admin/resources.go index baca7aa19..c458de762 100644 --- a/internal/rebac_admin/resources.go +++ b/internal/rebac_admin/resources.go @@ -5,10 +5,12 @@ package rebac_admin import ( "context" + v1 "github.com/canonical/rebac-admin-ui-handlers/v1" "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/errors" "github.com/canonical/jimm/v3/internal/jujuapi" "github.com/canonical/jimm/v3/internal/rebac_admin/utils" ) @@ -30,7 +32,15 @@ func (s *resourcesService) ListResources(ctx context.Context, params *resources. return nil, err } currentPage, expectedPageSize, pagination := pagination.CreatePaginationWithoutTotal(params.Size, params.Page) - res, err := s.jimm.ListResources(ctx, user, pagination) + namePrefixFilter, typeFilter := utils.GetNameAndTypeResourceFilter(params.EntityName, params.EntityType) + if typeFilter != "" { + typeFilter, err = validateAndConvertResourceFilter(typeFilter) + if err != nil { + return nil, v1.NewInvalidRequestError(err.Error()) + } + } + + res, err := s.jimm.ListResources(ctx, user, pagination, namePrefixFilter, typeFilter) if err != nil { return nil, err } @@ -58,10 +68,30 @@ func (s *resourcesService) ListResources(ctx context.Context, params *resources. // 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 { + if len(resources) > 0 && len(resources) == expectedPageSize { nPage := currentPage + 1 nextPage = &nPage resources = resources[:len(resources)-1] } return nextPage, resources } + +// validateAndConvertResourceFilter checks the typeFilter in the request and converts it to a valid key that is used to retrieved +// the right resources from the db. +func validateAndConvertResourceFilter(typeFilter string) (string, error) { + switch typeFilter { + case ApplicationOffer: + return db.ApplicationOffersQueryKey, nil + case Cloud: + return db.CloudsQueryKey, nil + case Controller: + return db.ControllersQueryKey, nil + case Model: + return db.ModelsQueryKey, nil + case ServiceAccount: + return db.ServiceAccountQueryKey, nil + default: + return "", errors.E("this resource type is not supported") + + } +} diff --git a/internal/rebac_admin/resources_integration_test.go b/internal/rebac_admin/resources_integration_test.go index dbb8bc352..fc2344110 100644 --- a/internal/rebac_admin/resources_integration_test.go +++ b/internal/rebac_admin/resources_integration_test.go @@ -3,6 +3,7 @@ package rebac_admin_test import ( "context" + "strings" rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" "github.com/canonical/rebac-admin-ui-handlers/v1/resources" @@ -69,25 +70,33 @@ func (s *resourcesSuite) TestListResources(c *gc.C) { env.PopulateDB(tester, s.JIMM.Database) type testEntity struct { Id string + Name string ParentId string + Type string } ids := make([]testEntity, 0) for _, c := range env.Clouds { ids = append(ids, testEntity{ Id: c.Name, + Name: c.Name, ParentId: "", + Type: "clouds", }) } for _, c := range env.Controllers { ids = append(ids, testEntity{ Id: c.UUID, + Name: c.Name, ParentId: "", + Type: "controllers", }) } for _, m := range env.Models { ids = append(ids, testEntity{ Id: m.UUID, + Name: m.Name, ParentId: env.Controller(m.Controller).UUID, + Type: "models", }) } @@ -95,6 +104,8 @@ func (s *resourcesSuite) TestListResources(c *gc.C) { desc string size *int page *int + nameFilter string + typeFilter string wantPage int wantSize int wantNextpage *int @@ -127,11 +138,77 @@ func (s *resourcesSuite) TestListResources(c *gc.C) { wantNextpage: nil, ids: []testEntity{ids[4]}, }, + { + desc: "test first page with model name filter", + size: utils.IntToPointer(2), + page: utils.IntToPointer(0), + nameFilter: "model", + wantPage: 0, + wantSize: 2, + wantNextpage: utils.IntToPointer(1), + ids: func() []testEntity { + filteredIds := make([]testEntity, 0) + for _, id := range ids { + if strings.HasPrefix(id.Name, "model") { + filteredIds = append(filteredIds, id) + } + } + return filteredIds + }()[:2], + }, + { + desc: "test first page with model name filter", + size: utils.IntToPointer(2), + page: utils.IntToPointer(1), + nameFilter: "model", + wantPage: 1, + wantSize: 1, + wantNextpage: nil, + ids: func() []testEntity { + filteredIds := make([]testEntity, 0) + for _, id := range ids { + if strings.Contains(id.Name, "model") { + filteredIds = append(filteredIds, id) + } + } + return filteredIds + }()[2:], + }, + { + desc: "test first page with controller entity type", + size: utils.IntToPointer(2), + page: utils.IntToPointer(0), + typeFilter: rebac_admin.Controller, + wantPage: 0, + wantSize: 1, + wantNextpage: nil, + ids: func() []testEntity { + filteredIds := make([]testEntity, 0) + for _, id := range ids { + if id.Type == "controllers" { + filteredIds = append(filteredIds, id) + } + } + return filteredIds + }(), + }, + { + desc: "test big page with model entity type", + size: utils.IntToPointer(10), + page: utils.IntToPointer(0), + typeFilter: rebac_admin.Model, + wantPage: 0, + wantSize: 3, + wantNextpage: nil, + ids: []testEntity{}, + }, } for _, t := range testCases { resources, err := resourcesSvc.ListResources(ctx, &resources.GetResourcesParams{ - Size: t.size, - Page: t.page, + Size: t.size, + Page: t.page, + EntityName: &t.nameFilter, + EntityType: &t.typeFilter, }) c.Assert(err, gc.IsNil) c.Assert(*resources.Meta.Page, gc.Equals, t.wantPage) diff --git a/internal/rebac_admin/resources_test.go b/internal/rebac_admin/resources_test.go new file mode 100644 index 000000000..6b9e21bee --- /dev/null +++ b/internal/rebac_admin/resources_test.go @@ -0,0 +1,81 @@ +// Copyright 2024 Canonical. + +package rebac_admin_test + +import ( + "context" + "testing" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + qt "github.com/frankban/quicktest" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/common/utils" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/openfga" + "github.com/canonical/jimm/v3/internal/rebac_admin" +) + +func TestListResources(t *testing.T) { + c := qt.New(t) + jimm := jimmtest.JIMM{ + ListResources_: func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, nameFilter, typeFilter string) ([]db.Resource, error) { + return []db.Resource{}, nil + }, + } + user := openfga.User{} + user.JimmAdmin = true + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + resourcesSvc := rebac_admin.NewResourcesService(&jimm) + + testCases := []struct { + desc string + size *int + page *int + nameFilter *string + typeFilter *string + expectErrorMatch string + }{ + { + desc: "test good", + size: utils.IntToPointer(2), + page: utils.IntToPointer(0), + nameFilter: utils.StringToPointer(""), + typeFilter: utils.StringToPointer(""), + }, + { + desc: "test good with all params set to nil", + size: nil, + page: nil, + nameFilter: nil, + typeFilter: nil, + }, + { + desc: "test with not valid type filter", + size: nil, + page: nil, + nameFilter: nil, + typeFilter: utils.StringToPointer("type-not-found"), + expectErrorMatch: ".*this resource type is not supported.*", + }, + } + for _, t := range testCases { + c.Run(t.desc, func(c *qt.C) { + _, err := resourcesSvc.ListResources(ctx, &resources.GetResourcesParams{ + Page: t.page, + Size: t.size, + EntityType: t.typeFilter, + EntityName: t.nameFilter, + }) + if t.expectErrorMatch != "" { + c.Assert(err, qt.ErrorMatches, t.expectErrorMatch) + } else { + c.Assert(err, qt.IsNil) + } + }) + } + +} diff --git a/internal/rebac_admin/utils/utils.go b/internal/rebac_admin/utils/utils.go index 3908b3d40..40a3e33d6 100644 --- a/internal/rebac_admin/utils/utils.go +++ b/internal/rebac_admin/utils/utils.go @@ -70,3 +70,13 @@ func ValidateDecomposedTag(kind string, id string) (names.Tag, error) { rawTag := kind + "-" + id return jimmnames.ParseTag(rawTag) } + +func GetNameAndTypeResourceFilter(nFilter, eFilter *string) (nameFilter, typeFilter string) { + if nFilter != nil { + nameFilter = *nFilter + } + if eFilter != nil { + typeFilter = *eFilter + } + return nameFilter, typeFilter +}