diff --git a/internal/jimm/model.go b/internal/jimm/model.go index 1c275d9f8..346fc8816 100644 --- a/internal/jimm/model.go +++ b/internal/jimm/model.go @@ -758,6 +758,92 @@ func (j *JIMM) ModelStatus(ctx context.Context, user *openfga.User, mt names.Mod return &ms, nil } +// dialAllController dials all controllers on JIMM and returns a closer +// to close all connections. Defer the closer. +// +// TODO(ale8k): Should this live here? We don't have like a utils.go in jimm pkg +func (j *JIMM) dialAllControllers() ([]API, func()) { + var controllers []dbmodel.Controller + apis := []API{} + + _ = j.DB().DB.Find(&controllers).Error + for _, c := range controllers { + api, err := j.dial(context.Background(), &c, names.ModelTag{}) + _ = err + apis = append(apis, api) + } + + closer := func() { + for _, a := range apis { + a.Close() + } + } + + return apis, closer +} + +// GetAllModelSummariesForUser dials all controllers JIMM is aware of and calls ListAllModelSummaries +// on each. It filters out controller models and ensures the user has access to the summarised +// models and filters out the results. +func (j *JIMM) GetAllModelSummariesForUser(ctx context.Context, user *openfga.User) (jujuparams.ModelSummaryResults, error) { + const op = errors.Op("jimm.GetAllModelSummariesForUser") + + apis, closer := j.dialAllControllers() + defer closer() + + filteredSummaries := jujuparams.ModelSummaryResults{} + flattenedSummaries := jujuparams.ModelSummaryResults{} + summaries := []jujuparams.ModelSummaryResults{} + + // Get all summaries from all controllers + for _, api := range apis { + var out jujuparams.ModelSummaryResults + in := jujuparams.ModelSummariesRequest{UserTag: names.NewUserTag("admin").String(), All: true} + if err := api.ListModelSummaries(context.Background(), &in, &out); err != nil { + return filteredSummaries, err + } + + summaries = append(summaries, out) + } + + // Flatten the summaries into a single results + for _, s := range summaries { + flattenedSummaries.Results = append(flattenedSummaries.Results, s.Results...) + } + + // Now we filter the flattened summaries for two things: + // 1. If it's an IsController, remove it + // 2. Check the user has access + for _, r := range flattenedSummaries.Results { + // Skip controller models + if r.Result.IsController { + continue + } + + access, err := j.GetUserModelAccess(context.Background(), user, names.NewModelTag(r.Result.UUID)) + if err != nil { + return filteredSummaries, errors.E(op, err) + } + + // Update the access to reflect our OpenFGA and not what the controller reported + // for the admin user that JIMM retrieved the model summary as. + r.Result.UserAccess = jujuparams.UserAccessPermission(access) + + switch access { + case "read": + fallthrough + case "write": + fallthrough + case "admin": + filteredSummaries.Results = append(filteredSummaries.Results, r) + default: + continue + } + } + + return filteredSummaries, nil +} + // ForEachUserModel calls the given function once for each model that the // given user has been granted explicit access to. The UserModelAccess // object passed to f will always include the Model_, Access, and diff --git a/internal/jimmtest/jimm_mock.go b/internal/jimmtest/jimm_mock.go index c30222ba1..3dfb21726 100644 --- a/internal/jimmtest/jimm_mock.go +++ b/internal/jimmtest/jimm_mock.go @@ -112,6 +112,14 @@ type JIMM struct { UpdateServiceAccountCredentials_ func() ValidateModelUpgrade_ func(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error WatchAllModelSummaries_ func(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) + GetAllModelSummariesForUser_ func(ctx context.Context, user *openfga.User) (jujuparams.ModelSummaryResults, error) +} + +func (j *JIMM) GetAllModelSummariesForUser(ctx context.Context, user *openfga.User) (jujuparams.ModelSummaryResults, error) { + if j.GetAllModelSummariesForUser_ == nil { + panic("not implemented") + } + return j.GetAllModelSummariesForUser(ctx, user) } func (j *JIMM) AddAuditLogEntry(ale *dbmodel.AuditLogEntry) { diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index 03652a542..3911474be 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -107,6 +107,7 @@ type JIMM interface { ValidateModelUpgrade(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error WatchAllModelSummaries(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) GetOpenFGAUserAndAuthorise(ctx context.Context, email string) (*openfga.User, error) + GetAllModelSummariesForUser(ctx context.Context, user *openfga.User) (jujuparams.ModelSummaryResults, error) } // controllerRoot is the root for endpoints served on controller connections. diff --git a/internal/jujuapi/modelmanager.go b/internal/jujuapi/modelmanager.go index 3587db3fa..92c2c2dea 100644 --- a/internal/jujuapi/modelmanager.go +++ b/internal/jujuapi/modelmanager.go @@ -9,7 +9,6 @@ import ( jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/canonical/jimm/internal/dbmodel" "github.com/canonical/jimm/internal/errors" "github.com/canonical/jimm/internal/jimm" "github.com/canonical/jimm/internal/jujuapi/rpc" @@ -81,26 +80,22 @@ func (r *controllerRoot) DumpModels(ctx context.Context, args jujuparams.DumpMod func (r *controllerRoot) ListModelSummaries(ctx context.Context, _ jujuparams.ModelSummariesRequest) (jujuparams.ModelSummaryResults, error) { const op = errors.Op("jujuapi.ListModelSummaries") - var results []jujuparams.ModelSummaryResult - err := r.jimm.ForEachUserModel(ctx, r.user, func(m *dbmodel.Model, access jujuparams.UserAccessPermission) error { - // TODO(Kian) CSS-6040 Refactor the below to use a better abstraction for Postgres/OpenFGA to Juju types. - ms := m.ToJujuModelSummary() - ms.UserAccess = access - if r.controllerUUIDMasking { + summaries, err := r.jimm.GetAllModelSummariesForUser(ctx, r.user) + if err != nil { + return summaries, err + } + + // If controller masking is set, don't reveal the underlying controllers UUID + // when performing a summary and instead set JIMM's controller ID for each. + if r.controllerUUIDMasking { + for _, results := range summaries.Results { + ms := results.Result ms.ControllerUUID = r.params.ControllerUUID } - result := jujuparams.ModelSummaryResult{ - Result: &ms, - } - results = append(results, result) - return nil - }) - if err != nil { - return jujuparams.ModelSummaryResults{}, errors.E(op, err) } - return jujuparams.ModelSummaryResults{ - Results: results, - }, nil + + // Return the masked summaries from all underlying controllers. + return summaries, err } // ListModels returns the models that the authenticated user diff --git a/internal/jujuapi/modelmanager_test.go b/internal/jujuapi/modelmanager_test.go index 3a5bfb87c..90ad9d145 100644 --- a/internal/jujuapi/modelmanager_test.go +++ b/internal/jujuapi/modelmanager_test.go @@ -41,6 +41,69 @@ type modelManagerSuite struct { var _ = gc.Suite(&modelManagerSuite{}) +func (s *modelManagerSuite) TestListModelSummariesV2(c *gc.C) { + conn := s.open(c, nil, "bob") + defer conn.Close() + + client := modelmanager.NewClient(conn) + + // List for bob + models, err := client.ListModelSummaries("bob@canonical.com", false) + c.Assert(err, gc.Equals, nil) + c.Assert(models, jimmtest.CmpEquals( + cmpopts.IgnoreTypes(&time.Time{}), + cmpopts.SortSlices(func(a, b base.UserModelSummary) bool { + return a.Name < b.Name + }), + ), []base.UserModelSummary{{ + Name: "model-1", + UUID: s.Model.UUID.String, + ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ProviderType: jimmtest.TestProviderType, + DefaultSeries: "jammy", + Cloud: jimmtest.TestCloudName, + CloudRegion: jimmtest.TestCloudRegionName, + CloudCredential: jimmtest.TestCloudName + "/bob@canonical.com/cred", + Owner: "bob@canonical.com", + Life: life.Value(state.Alive.String()), + Status: base.Status{ + Status: status.Available, + Data: map[string]interface{}{}, + }, + ModelUserAccess: "admin", + Counts: []base.EntityCount{}, + AgentVersion: &jujuversion.Current, + Type: "iaas", + SLA: &base.SLASummary{ + Level: "", + Owner: "bob@canonical.com", + }, + }, { + Name: "model-3", + UUID: s.Model3.UUID.String, + ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ProviderType: jimmtest.TestProviderType, + DefaultSeries: "jammy", + Cloud: jimmtest.TestCloudName, + CloudRegion: jimmtest.TestCloudRegionName, + CloudCredential: jimmtest.TestCloudName + "/charlie@canonical.com/cred", + Owner: "charlie@canonical.com", + Life: life.Value(state.Alive.String()), + Status: base.Status{ + Status: status.Available, + Data: map[string]interface{}{}, + }, + ModelUserAccess: "read", + Counts: []base.EntityCount{}, + AgentVersion: &jujuversion.Current, + Type: "iaas", + SLA: &base.SLASummary{ + Level: "", + Owner: "charlie@canonical.com", + }, + }}) +} + func (s *modelManagerSuite) TestListModelSummaries(c *gc.C) { conn := s.open(c, nil, "bob") defer conn.Close()