From 212fb3170f0f7468428b05af4f5e1aa4354102a8 Mon Sep 17 00:00:00 2001 From: Kian Parvin <46668016+kian99@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:00:13 +0200 Subject: [PATCH] feat: add permission manager (#1507) * feat: introduce permission manager Moved group methods to group package Moved createTestControllerEnvironment function to jimmtest package * chore: add juju auth factory * chore: fixed access tests * chore: fix relations tests * chore: refactor grant/revoke offer tests * chore: change exported surface of jujuauth pkg * chore: fixes after rebase * chore: fix list offers test Listing offers test was failing because model owner was only expected to have consume access on offers but should have admin access. * chore: add missing docstrings * chore: add check for missing jimm tag * chore: update copyright headers Also updated constructor from NewPermissionManager to NewManager * chore: minor renaming --------- Co-authored-by: Ales Stimec --- cmd/jimmsrv/service/service.go | 2 +- internal/jimm/access_test.go | 440 --- internal/jimm/applicationoffer.go | 119 +- internal/jimm/applicationoffer_test.go | 448 +--- internal/jimm/cloud.go | 154 +- internal/jimm/cloud_test.go | 467 ---- internal/jimm/controller.go | 45 - internal/jimm/controller_test.go | 46 - internal/jimm/export_test.go | 17 +- internal/jimm/jimm.go | 76 + internal/jimm/jujuauth/factory.go | 26 + internal/jimm/jujuauth/jwtgenerator.go | 6 +- internal/jimm/jujuauth/jwtgenerator_test.go | 10 +- internal/jimm/login/login.go | 3 + internal/jimm/model.go | 176 +- internal/jimm/model_test.go | 992 ------- internal/jimm/permissions/access.go | 799 ++++++ internal/jimm/permissions/access_test.go | 2383 +++++++++++++++++ internal/jimm/permissions/export_test.go | 21 + .../jimm/permissions/permissionmanager.go | 32 + .../{relation.go => permissions/relations.go} | 30 +- .../relations_test.go} | 37 +- internal/jimm/permissions/suite_test.go | 71 + .../{access.go => permissions/tagresolver.go} | 301 +-- internal/jimm/service_account.go | 40 - internal/jimm/service_account_test.go | 94 - internal/jimmhttp/rebac_admin/groups.go | 18 +- internal/jimmhttp/rebac_admin/groups_test.go | 84 +- internal/jimmhttp/rebac_admin/identities.go | 18 +- .../identities_integration_test.go | 8 +- .../jimmhttp/rebac_admin/identities_test.go | 56 +- internal/jimmhttp/rebac_admin/roles.go | 6 +- internal/jimmhttp/rebac_admin/roles_test.go | 28 +- internal/jujuapi/access_control.go | 12 +- internal/jujuapi/applicationoffers.go | 4 +- internal/jujuapi/cloud.go | 7 +- internal/jujuapi/controller.go | 2 +- internal/jujuapi/interface.go | 20 +- internal/jujuapi/jimm.go | 4 +- internal/jujuapi/jimm_relation.go | 17 - internal/jujuapi/modelmanager.go | 4 +- internal/jujuapi/service_account.go | 2 +- internal/jujuapi/service_account_test.go | 7 +- internal/jujuapi/websocket.go | 2 +- internal/testutils/jimmtest/env.go | 37 +- internal/testutils/jimmtest/jimm_mock.go | 131 +- .../jimmtest/mocks/jimm_relation_mock.go | 144 +- 47 files changed, 3836 insertions(+), 3610 deletions(-) delete mode 100644 internal/jimm/access_test.go create mode 100644 internal/jimm/jujuauth/factory.go create mode 100644 internal/jimm/permissions/access.go create mode 100644 internal/jimm/permissions/access_test.go create mode 100644 internal/jimm/permissions/export_test.go create mode 100644 internal/jimm/permissions/permissionmanager.go rename internal/jimm/{relation.go => permissions/relations.go} (79%) rename internal/jimm/{relation_test.go => permissions/relations_test.go} (90%) create mode 100644 internal/jimm/permissions/suite_test.go rename internal/jimm/{access.go => permissions/tagresolver.go} (58%) diff --git a/cmd/jimmsrv/service/service.go b/cmd/jimmsrv/service/service.go index 215ab004c..890416d37 100644 --- a/cmd/jimmsrv/service/service.go +++ b/cmd/jimmsrv/service/service.go @@ -254,7 +254,7 @@ func (s *Service) OpenFGACleanup(ctx context.Context, trigger <-chan time.Time) for { select { case <-trigger: - err := s.jimm.OpenFGACleanup(ctx) + err := s.jimm.PermissionManager().OpenFGACleanup(ctx) if err != nil { zapctx.Error(ctx, "openfga cleanup", zap.Error(err)) continue diff --git a/internal/jimm/access_test.go b/internal/jimm/access_test.go deleted file mode 100644 index 4d9d83107..000000000 --- a/internal/jimm/access_test.go +++ /dev/null @@ -1,440 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm_test - -import ( - "context" - "fmt" - "testing" - - "github.com/canonical/ofga" - petname "github.com/dustinkirkland/golang-petname" - qt "github.com/frankban/quicktest" - "github.com/google/uuid" - "github.com/juju/names/v5" - - "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/jimm" - "github.com/canonical/jimm/v3/internal/openfga" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" - "github.com/canonical/jimm/v3/internal/testutils/jimmtest" - jimmnames "github.com/canonical/jimm/v3/pkg/names" -) - -func TestAuditLogAccess(t *testing.T) { - c := qt.New(t) - - j := jimmtest.NewJIMM(c, nil) - - ctx := context.Background() - - i, err := dbmodel.NewIdentity("alice") - c.Assert(err, qt.IsNil) - adminUser := openfga.NewUser(i, j.OpenFGAClient) - err = adminUser.SetControllerAccess(ctx, j.ResourceTag(), ofganames.AdministratorRelation) - c.Assert(err, qt.IsNil) - - i2, err := dbmodel.NewIdentity("bob") - c.Assert(err, qt.IsNil) - user := openfga.NewUser(i2, j.OpenFGAClient) - - // admin user can grant other users audit log access. - err = j.GrantAuditLogAccess(ctx, adminUser, user.ResourceTag()) - c.Assert(err, qt.IsNil) - - access := user.GetAuditLogViewerAccess(ctx, j.ResourceTag()) - c.Assert(access, qt.Equals, ofganames.AuditLogViewerRelation) - - // re-granting access does not result in error. - err = j.GrantAuditLogAccess(ctx, adminUser, user.ResourceTag()) - c.Assert(err, qt.IsNil) - - // admin user can revoke other users audit log access. - err = j.RevokeAuditLogAccess(ctx, adminUser, user.ResourceTag()) - c.Assert(err, qt.IsNil) - - access = user.GetAuditLogViewerAccess(ctx, j.ResourceTag()) - c.Assert(access, qt.Equals, ofganames.NoRelation) - - // re-revoking access does not result in error. - err = j.RevokeAuditLogAccess(ctx, adminUser, user.ResourceTag()) - c.Assert(err, qt.IsNil) - - // non-admin user cannot grant audit log access - err = j.GrantAuditLogAccess(ctx, user, adminUser.ResourceTag()) - c.Assert(err, qt.ErrorMatches, "unauthorized") - - // non-admin user cannot revoke audit log access - err = j.RevokeAuditLogAccess(ctx, user, adminUser.ResourceTag()) - c.Assert(err, qt.ErrorMatches, "unauthorized") -} - -func TestParseAndValidateTag(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - user, _, _, model, _, _, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) - - jimmTag := "model-" + user.Name + "/" + model.Name + "#administrator" - - // JIMM tag syntax for models - tag, err := j.ParseAndValidateTag(ctx, jimmTag) - c.Assert(err, qt.IsNil) - c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) - c.Assert(tag.ID, qt.Equals, model.UUID.String) - c.Assert(tag.Relation.String(), qt.Equals, "administrator") - - jujuTag := "model-" + model.UUID.String + "#administrator" - - // Juju tag syntax for models - tag, err = j.ParseAndValidateTag(ctx, jujuTag) - c.Assert(err, qt.IsNil) - c.Assert(tag.ID, qt.Equals, model.UUID.String) - c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) - c.Assert(tag.Relation.String(), qt.Equals, "administrator") - - // JIMM tag only kind - kindTag := "model" - tag, err = j.ParseAndValidateTag(ctx, kindTag) - c.Assert(err, qt.IsNil) - c.Assert(tag.ID, qt.Equals, "") - c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) - - // JIMM tag not valid - _, err = j.ParseAndValidateTag(ctx, "") - c.Assert(err, qt.ErrorMatches, "unknown tag kind") -} - -func TestResolveTags(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - identity, group, controller, model, offer, cloud, _, role := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) - - testCases := []struct { - desc string - input string - expected *ofga.Entity - }{{ - desc: "map identity name with relation", - input: "user-" + identity.Name + "#member", - expected: ofganames.ConvertTagWithRelation(names.NewUserTag(identity.Name), ofganames.MemberRelation), - }, { - desc: "map group name with relation", - input: "group-" + group.Name + "#member", - expected: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), - }, { - desc: "map group UUID", - input: "group-" + group.UUID, - expected: ofganames.ConvertTag(jimmnames.NewGroupTag(group.UUID)), - }, { - desc: "map group UUID with relation", - input: "group-" + group.UUID + "#member", - expected: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), - }, { - desc: "map role UUID", - input: "role-" + role.UUID, - expected: ofganames.ConvertTag(jimmnames.NewRoleTag(role.UUID)), - }, { - desc: "map role UUID with relation", - input: "role-" + role.UUID + "#assignee", - expected: ofganames.ConvertTagWithRelation(jimmnames.NewRoleTag(role.UUID), ofganames.AssigneeRelation), - }, { - desc: "map jimm controller", - input: "controller-" + "jimm", - expected: ofganames.ConvertTag(names.NewControllerTag(j.UUID)), - }, { - desc: "map controller", - input: "controller-" + controller.Name + "#administrator", - expected: ofganames.ConvertTagWithRelation(names.NewControllerTag(model.UUID.String), ofganames.AdministratorRelation), - }, { - desc: "map controller UUID", - input: "controller-" + controller.UUID, - expected: ofganames.ConvertTag(names.NewControllerTag(model.UUID.String)), - }, { - desc: "map model", - input: "model-" + model.OwnerIdentityName + "/" + model.Name + "#administrator", - expected: ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), ofganames.AdministratorRelation), - }, { - desc: "map model UUID", - input: "model-" + model.UUID.String, - expected: ofganames.ConvertTag(names.NewModelTag(model.UUID.String)), - }, { - desc: "map offer", - input: "applicationoffer-" + offer.URL + "#administrator", - expected: ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), ofganames.AdministratorRelation), - }, { - desc: "map offer UUID", - input: "applicationoffer-" + offer.UUID, - expected: ofganames.ConvertTag(names.NewApplicationOfferTag(offer.UUID)), - }, { - desc: "map cloud", - input: "cloud-" + cloud.Name + "#administrator", - expected: ofganames.ConvertTagWithRelation(names.NewCloudTag(cloud.Name), ofganames.AdministratorRelation), - }} - - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - jujuTag, err := jimm.ResolveTag(j.UUID, j.Database, tC.input) - c.Assert(err, qt.IsNil) - c.Assert(jujuTag, qt.DeepEquals, tC.expected) - }) - } -} - -func TestResolveTupleObjectHandlesErrors(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - _, _, controller, model, offer, _, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) - - type test struct { - input string - want string - } - - tests := []test{ - // Resolves bad tuple objects in general - { - input: "unknowntag-blabla", - want: "failed to map tag, unknown kind: unknowntag", - }, - // Resolves bad groups where they do not exist - { - input: "group-myspecialpokemon-his-name-is-youguessedit-diglett", - want: "group myspecialpokemon-his-name-is-youguessedit-diglett not found", - }, - // Resolves bad controllers where they do not exist - { - input: "controller-mycontroller-that-does-not-exist", - want: "controller not found", - }, - // Resolves bad models where the user cannot be obtained from the JIMM tag - { - input: "model-mycontroller-that-does-not-exist/mymodel", - want: "model not found", - }, - // Resolves bad models where it cannot be found on the specified controller - { - input: "model-" + controller.Name + ":alex/", - want: "model name format incorrect, expected /", - }, - // Resolves bad applicationoffers where it cannot be found on the specified controller/model combo - { - input: "applicationoffer-" + controller.Name + ":alex/" + model.Name + "." + offer.UUID + "fluff", - want: "application offer not found", - }, - { - input: "abc", - want: "failed to setup tag resolver: tag is not properly formatted", - }, - { - input: "model-test-unknowncontroller-1:alice@canonical.com/test-model-1", - want: "model not found", - }, - } - for i, tc := range tests { - t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { - _, err := jimm.ResolveTag(j.UUID, j.Database, tc.input) - c.Assert(err, qt.ErrorMatches, tc.want) - }) - } -} - -func TestToJAASTag(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - user, group, controller, model, applicationOffer, cloud, _, role := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) - - serviceAccountId := petname.Generate(2, "-") + "@serviceaccount" - - tests := []struct { - tag *ofganames.Tag - expectedJAASTag string - expectedError string - }{{ - tag: ofganames.ConvertTag(user.ResourceTag()), - expectedJAASTag: "user-" + user.Name, - }, { - tag: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(serviceAccountId)), - expectedJAASTag: "serviceaccount-" + serviceAccountId, - }, { - tag: ofganames.ConvertTag(group.ResourceTag()), - expectedJAASTag: "group-" + group.Name, - }, { - tag: ofganames.ConvertTag(controller.ResourceTag()), - expectedJAASTag: "controller-" + controller.Name, - }, { - tag: ofganames.ConvertTag(model.ResourceTag()), - expectedJAASTag: "model-" + user.Name + "/" + model.Name, - }, { - tag: ofganames.ConvertTag(applicationOffer.ResourceTag()), - expectedJAASTag: "applicationoffer-" + applicationOffer.URL, - }, { - tag: &ofganames.Tag{}, - expectedError: "unexpected tag kind: ", - }, { - tag: ofganames.ConvertTag(cloud.ResourceTag()), - expectedJAASTag: "cloud-" + cloud.Name, - }, { - tag: ofganames.ConvertTag(role.ResourceTag()), - expectedJAASTag: "role-" + role.Name, - }} - for _, test := range tests { - t, err := j.ToJAASTag(ctx, test.tag, true) - if test.expectedError != "" { - c.Assert(err, qt.ErrorMatches, test.expectedError) - } else { - c.Assert(err, qt.IsNil) - c.Assert(t, qt.Equals, test.expectedJAASTag) - } - } -} - -func TestToJAASTagNoUUIDResolution(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - user, group, controller, model, applicationOffer, cloud, _, role := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) - serviceAccountId := petname.Generate(2, "-") + "@serviceaccount" - - tests := []struct { - tag *ofganames.Tag - expectedJAASTag string - expectedError string - }{{ - tag: ofganames.ConvertTag(user.ResourceTag()), - expectedJAASTag: "user-" + user.Name, - }, { - tag: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(serviceAccountId)), - expectedJAASTag: "serviceaccount-" + serviceAccountId, - }, { - tag: ofganames.ConvertTag(group.ResourceTag()), - expectedJAASTag: "group-" + group.UUID, - }, { - tag: ofganames.ConvertTag(controller.ResourceTag()), - expectedJAASTag: "controller-" + controller.UUID, - }, { - tag: ofganames.ConvertTag(model.ResourceTag()), - expectedJAASTag: "model-" + model.UUID.String, - }, { - tag: ofganames.ConvertTag(applicationOffer.ResourceTag()), - expectedJAASTag: "applicationoffer-" + applicationOffer.UUID, - }, { - tag: ofganames.ConvertTag(cloud.ResourceTag()), - expectedJAASTag: "cloud-" + cloud.Name, - }, { - tag: ofganames.ConvertTag(role.ResourceTag()), - expectedJAASTag: "role-" + role.UUID, - }, { - tag: &ofganames.Tag{}, - expectedJAASTag: "-", - }} - for _, test := range tests { - t, err := j.ToJAASTag(ctx, test.tag, false) - if test.expectedError != "" { - c.Assert(err, qt.ErrorMatches, test.expectedError) - } else { - c.Assert(err, qt.IsNil) - c.Assert(t, qt.Equals, test.expectedJAASTag) - } - } -} - -func TestOpenFGACleanup(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - // run cleanup on an empty authorizaton store - err := j.OpenFGACleanup(ctx) - c.Assert(err, qt.IsNil) - - type createTagFunction func(int) *ofga.Entity - - var ( - createStringTag = func(kind openfga.Kind) createTagFunction { - return func(i int) *ofga.Entity { - return &ofga.Entity{ - Kind: kind, - ID: fmt.Sprintf("%s-%d", petname.Generate(2, "-"), i), - } - } - } - - createUUIDTag = func(kind openfga.Kind) createTagFunction { - return func(i int) *ofga.Entity { - return &ofga.Entity{ - Kind: kind, - ID: uuid.NewString(), - } - } - } - ) - - tagTests := []struct { - createObjectTag createTagFunction - relation string - createTargetTag createTagFunction - }{{ - createObjectTag: createStringTag(openfga.UserType), - relation: "member", - createTargetTag: createStringTag(openfga.GroupType), - }, { - createObjectTag: createStringTag(openfga.UserType), - relation: "administrator", - createTargetTag: createUUIDTag(openfga.ControllerType), - }, { - createObjectTag: createStringTag(openfga.UserType), - relation: "reader", - createTargetTag: createUUIDTag(openfga.ModelType), - }, { - createObjectTag: createStringTag(openfga.UserType), - relation: "administrator", - createTargetTag: createStringTag(openfga.CloudType), - }, { - createObjectTag: createStringTag(openfga.UserType), - relation: "consumer", - createTargetTag: createUUIDTag(openfga.ApplicationOfferType), - }} - - orphanedTuples := []ofga.Tuple{} - for i := 0; i < 100; i++ { - for _, test := range tagTests { - objectTag := test.createObjectTag(i) - targetTag := test.createTargetTag(i) - - tuple := openfga.Tuple{ - Object: objectTag, - Relation: ofga.Relation(test.relation), - Target: targetTag, - } - err = j.OpenFGAClient.AddRelation(ctx, tuple) - c.Assert(err, qt.IsNil) - - orphanedTuples = append(orphanedTuples, tuple) - } - } - - err = j.OpenFGACleanup(ctx) - c.Assert(err, qt.IsNil) - - for _, tuple := range orphanedTuples { - c.Logf("checking relation for %+v", tuple) - ok, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(ok, qt.IsFalse) - } -} diff --git a/internal/jimm/applicationoffer.go b/internal/jimm/applicationoffer.go index 0591d683c..5cb1842c6 100644 --- a/internal/jimm/applicationoffer.go +++ b/internal/jimm/applicationoffer.go @@ -22,6 +22,7 @@ import ( "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm/permissions" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" ) @@ -279,7 +280,7 @@ func (j *JIMM) listApplicationOfferUsers(ctx context.Context, offer names.Applic if users[user.Name] != "" { continue } - users[user.Name] = ToOfferAccessString(relation) + users[user.Name] = permissions.ToOfferAccessString(relation) } } @@ -390,122 +391,6 @@ func (j *JIMM) GetApplicationOffer(ctx context.Context, user *openfga.User, offe return &offerDetails, nil } -// GrantOfferAccess grants rights for an application offer. -func (j *JIMM) GrantOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error { - const op = errors.Op("jimm.GrantOfferAccess") - - identity, err := dbmodel.NewIdentity(ut.Id()) - if err != nil { - return errors.E(op, err) - } - - err = j.doApplicationOfferAdmin(ctx, user, offerURL, func(offer *dbmodel.ApplicationOffer, api API) error { - tUser := openfga.NewUser(identity, j.OpenFGAClient) - currentRelation := tUser.GetApplicationOfferAccess(ctx, offer.ResourceTag()) - currentAccessLevel := ToOfferAccessString(currentRelation) - targetAccessLevel := determineAccessLevelAfterGrant(currentAccessLevel, string(access)) - - // NOTE (alesstimec) not removing the current access level as it might be an - // indirect relation. - if targetAccessLevel != currentAccessLevel { - relation, err := ToOfferRelation(targetAccessLevel) - if err != nil { - return errors.E(op, err) - } - err = tUser.SetApplicationOfferAccess(ctx, offer.ResourceTag(), relation) - if err != nil { - return errors.E(op, err) - } - } - - return nil - }) - - if err != nil { - return errors.E(op, err) - } - return nil -} - -func determineAccessLevelAfterGrant(currentAccessLevel, grantAccessLevel string) string { - switch currentAccessLevel { - case string(jujuparams.OfferAdminAccess): - return string(jujuparams.OfferAdminAccess) - case string(jujuparams.OfferConsumeAccess): - switch grantAccessLevel { - case string(jujuparams.OfferAdminAccess): - return string(jujuparams.OfferAdminAccess) - default: - return string(jujuparams.OfferConsumeAccess) - } - case string(jujuparams.OfferReadAccess): - switch grantAccessLevel { - case string(jujuparams.OfferAdminAccess): - return string(jujuparams.OfferAdminAccess) - case string(jujuparams.OfferConsumeAccess): - return string(jujuparams.OfferConsumeAccess) - default: - return string(jujuparams.OfferReadAccess) - } - default: - return grantAccessLevel - } -} - -// RevokeOfferAccess revokes rights for an application offer. -func (j *JIMM) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) { - const op = errors.Op("jimm.RevokeOfferAccess") - - identity, err := dbmodel.NewIdentity(ut.Id()) - if err != nil { - return errors.E(op, err) - } - - err = j.doApplicationOfferAdmin(ctx, user, offerURL, func(offer *dbmodel.ApplicationOffer, api API) error { - tUser := openfga.NewUser(identity, j.OpenFGAClient) - targetRelation, err := ToOfferRelation(string(access)) - if err != nil { - return errors.E(op, err) - } - err = tUser.UnsetApplicationOfferAccess(ctx, offer.ResourceTag(), targetRelation) - if err != nil { - return errors.E(op, err, "failed to unset given access") - } - - // Checking if the target user still has the given access to the - // application offer (which is possible because of indirect relations), - // and if so, returning an informative error. - currentRelation := tUser.GetApplicationOfferAccess(ctx, offer.ResourceTag()) - stillHasAccess := false - switch targetRelation { - case ofganames.AdministratorRelation: - if currentRelation == ofganames.AdministratorRelation { - stillHasAccess = true - } - case ofganames.ConsumerRelation: - switch currentRelation { - case ofganames.AdministratorRelation, ofganames.ConsumerRelation: - stillHasAccess = true - } - case ofganames.ReaderRelation: - switch currentRelation { - case ofganames.AdministratorRelation, ofganames.ConsumerRelation, ofganames.ReaderRelation: - stillHasAccess = true - } - } - - if stillHasAccess { - return errors.E(op, "unable to completely revoke given access due to other relations; try to remove them as well, or use 'jimmctl' for more control") - } - return nil - }) - - if err != nil { - return errors.E(op, err) - } - return nil -} - // DestroyOffer removes the application offer. func (j *JIMM) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error { const op = errors.Op("jimm.DestroyOffer") diff --git a/internal/jimm/applicationoffer_test.go b/internal/jimm/applicationoffer_test.go index 0f3f32e10..0014d05ee 100644 --- a/internal/jimm/applicationoffer_test.go +++ b/internal/jimm/applicationoffer_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jimm_test @@ -179,340 +179,6 @@ var initializeEnvironment = func(c *qt.C, ctx context.Context, db *db.Database, return &env } -func TestRevokeOfferAccess(t *testing.T) { - c := qt.New(t) - - ctx := context.Background() - - tests := []struct { - about string - parameterFunc func(*environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) - setup func(*environment, *openfga.OFGAClient) - expectedError string - expectedAccessLevel string - expectedAccessLevelOnError string // This expectation is meant to ensure there'll be no unpredicted behavior (like changing existing relations) after an error has occurred - }{{ - about: "admin revokes a model admin user's admin access - an error returns (relation is indirect)", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[1], env.users[0], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedError: "unable to completely revoke given access due to other relations.*", - expectedAccessLevelOnError: "admin", - }, { - about: "model admin revokes an admin user admin access - user has no access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[1], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "", - }, { - about: "admin revokes an admin user admin access - user has no access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[5], env.users[1], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "", - }, { - about: "superuser revokes an admin user admin access - user has no access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[6], env.users[1], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "", - }, { - about: "admin revokes an admin user read access - an error returns (no direct relation to remove)", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[1], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedError: "unable to completely revoke given access due to other relations.*", - expectedAccessLevelOnError: "admin", - }, { - about: "admin revokes a consume user admin access - user keeps consume access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[2], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "consume", - }, { - about: "admin revokes a consume user consume access - user has no access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[2], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedAccessLevel: "", - }, { - about: "admin revokes a consume user read access - user still has consume access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[2], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedError: "unable to completely revoke given access due to other relations.*", - expectedAccessLevelOnError: "consume", - }, { - about: "admin revokes a read user admin access - user keeps read access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[3], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "read", - }, { - about: "admin revokes a read user consume access - user keeps read access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[3], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedAccessLevel: "read", - }, { - about: "admin revokes a read user read access - user has no access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[3], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedAccessLevel: "", - }, { - about: "admin tries to revoke access to user that does not have access - user continues to have no access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[4], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedAccessLevel: "", - }, { - about: "user with consume access cannot revoke access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[2], env.users[3], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedError: "unauthorized", - }, { - about: "user with read access cannot revoke access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[3], env.users[3], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedError: "unauthorized", - }, { - about: "no such offer", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[3], env.users[3], "no-such-offer", jujuparams.OfferReadAccess - }, - expectedError: "application offer not found", - }, { - about: "admin revokes another user (who is direct admin+consumer) their consume access - an error returns (saying user still has access; hinting to use 'jimmctl' for advanced cases)", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[1], env.users[4], env.applicationOffers[0].URL, jujuparams.OfferConsumeAccess - }, - setup: func(env *environment, client *openfga.OFGAClient) { - err := openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ConsumerRelation) - c.Assert(err, qt.IsNil) - err = openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.AdministratorRelation) - c.Assert(err, qt.IsNil) - }, - expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", - expectedAccessLevelOnError: "admin", - }, { - about: "admin revokes another user (who is direct admin+reader) their read access - an error returns (saying user still has access; hinting to use 'jimmctl' for advanced cases)", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[1], env.users[4], env.applicationOffers[0].URL, jujuparams.OfferReadAccess - }, - setup: func(env *environment, client *openfga.OFGAClient) { - err := openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ReaderRelation) - c.Assert(err, qt.IsNil) - err = openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.AdministratorRelation) - c.Assert(err, qt.IsNil) - }, - expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", - expectedAccessLevelOnError: "admin", - }, { - about: "admin revokes another user (who is direct consumer+reader) their read access - an error returns (saying user still has access; hinting to use 'jimmctl' for advanced cases)", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[1], env.users[4], env.applicationOffers[0].URL, jujuparams.OfferReadAccess - }, - setup: func(env *environment, client *openfga.OFGAClient) { - err := openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ReaderRelation) - c.Assert(err, qt.IsNil) - err = openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ConsumerRelation) - c.Assert(err, qt.IsNil) - }, - expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", - expectedAccessLevelOnError: "consume", - }} - - for _, test := range tests { - c.Run(test.about, func(c *qt.C) { - jimmUUID := uuid.NewString() - - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - Dialer: &jimmtest.Dialer{ - API: &jimmtest.API{}, - }, - }) - - environment := initializeEnvironment(c, ctx, j.Database, j.OpenFGAClient, jimmUUID) - - if test.setup != nil { - test.setup(environment, j.OpenFGAClient) - } - authenticatedUser, offerUser, offerURL, revokeAccessLevel := test.parameterFunc(environment) - - assertAppliedRelation := func(expectedAppliedRelation string) { - offer := dbmodel.ApplicationOffer{ - URL: offerURL, - } - err := j.Database.GetApplicationOffer(ctx, &offer) - c.Assert(err, qt.IsNil) - appliedRelation := openfga.NewUser(&offerUser, j.OpenFGAClient).GetApplicationOfferAccess(ctx, offer.ResourceTag()) - c.Assert(jimm.ToOfferAccessString(appliedRelation), qt.Equals, expectedAppliedRelation) - } - - err := j.RevokeOfferAccess(ctx, openfga.NewUser(&authenticatedUser, j.OpenFGAClient), offerURL, offerUser.ResourceTag(), revokeAccessLevel) - if test.expectedError == "" { - c.Assert(err, qt.IsNil) - assertAppliedRelation(test.expectedAccessLevel) - } else { - c.Assert(err, qt.ErrorMatches, test.expectedError) - if test.expectedAccessLevelOnError != "" { - assertAppliedRelation(test.expectedAccessLevelOnError) - } - } - }) - } -} - -func TestGrantOfferAccess(t *testing.T) { - c := qt.New(t) - - ctx := context.Background() - - tests := []struct { - about string - parameterFunc func(*environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) - expectedError string - expectedAccessLevel string - }{{ - about: "model admin grants an admin user admin access - admin user keeps admin", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[1], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "admin", - }, { - about: "model admin grants an admin user consume access - admin user keeps admin", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[1], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedAccessLevel: "admin", - }, { - about: "model admin grants an admin user read access - admin user keeps admin", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[1], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedAccessLevel: "admin", - }, { - about: "model admin grants a consume user admin access - user gets admin access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[2], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "admin", - }, { - about: "admin grants a consume user admin access - user gets admin access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[5], env.users[2], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "admin", - }, { - about: "superuser grants a consume user admin access - user gets admin access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[6], env.users[2], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "admin", - }, { - about: "admin grants a consume user consume access - user keeps consume access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[2], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedAccessLevel: "consume", - }, { - about: "admin grants a consume user read access - use keeps consume access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[2], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedAccessLevel: "consume", - }, { - about: "admin grants a read user admin access - user gets admin access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[3], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "admin", - }, { - about: "admin grants a read user consume access - user gets consume access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[3], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedAccessLevel: "consume", - }, { - about: "admin grants a read user read access - user keeps read access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[3], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedAccessLevel: "read", - }, { - about: "no such offer", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[3], "no-such-offer", jujuparams.OfferReadAccess - }, - expectedError: "application offer not found", - }, { - about: "user with consume rights cannot grant any rights", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[2], env.users[4], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedError: "unauthorized", - }, { - about: "user with read rights cannot grant any rights", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[3], env.users[4], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedError: "unauthorized", - }, { - about: "admin grants new user admin access - new user has admin access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[4], "test-offer-url", jujuparams.OfferAdminAccess - }, - expectedAccessLevel: "admin", - }, { - about: "admin grants new user consume access - new user has consume access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[4], "test-offer-url", jujuparams.OfferConsumeAccess - }, - expectedAccessLevel: "consume", - }, { - about: "admin grants new user read access - new user has read access", - parameterFunc: func(env *environment) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { - return env.users[0], env.users[4], "test-offer-url", jujuparams.OfferReadAccess - }, - expectedAccessLevel: "read", - }} - - for _, test := range tests { - c.Run(test.about, func(c *qt.C) { - jimmUUID := uuid.NewString() - - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - UUID: jimmUUID, - - Dialer: &jimmtest.Dialer{ - API: &jimmtest.API{}, - }, - }) - - environment := initializeEnvironment(c, ctx, j.Database, j.OpenFGAClient, jimmUUID) - authenticatedUser, offerUser, offerURL, grantAccessLevel := test.parameterFunc(environment) - - err := j.GrantOfferAccess(ctx, openfga.NewUser(&authenticatedUser, j.OpenFGAClient), offerURL, offerUser.ResourceTag(), grantAccessLevel) - if test.expectedError == "" { - c.Assert(err, qt.IsNil) - - offer := dbmodel.ApplicationOffer{ - URL: offerURL, - } - err = j.Database.GetApplicationOffer(ctx, &offer) - c.Assert(err, qt.IsNil) - appliedRelation := openfga.NewUser(&offerUser, j.OpenFGAClient).GetApplicationOfferAccess(ctx, offer.ResourceTag()) - c.Assert(jimm.ToOfferAccessString(appliedRelation), qt.Equals, test.expectedAccessLevel) - } else { - c.Assert(err, qt.ErrorMatches, test.expectedError) - } - }) - } -} - func TestGetApplicationOfferConsumeDetails(t *testing.T) { c := qt.New(t) @@ -1790,84 +1456,6 @@ func TestOfferAssertOpenFGARelationsExist(t *testing.T) { c.Assert(exists, qt.IsTrue) } -func TestDetermineAccessLevelAfterGrant(t *testing.T) { - c := qt.New(t) - - tests := []struct { - about string - currentAccessLevel string - grantAccessLevel string - expectedAccessLevel string - }{{ - about: "user has no access - grant admin", - currentAccessLevel: "", - grantAccessLevel: string(jujuparams.OfferAdminAccess), - expectedAccessLevel: "admin", - }, { - about: "user has no access - grant consume", - currentAccessLevel: "", - grantAccessLevel: string(jujuparams.OfferConsumeAccess), - expectedAccessLevel: "consume", - }, { - about: "user has no access - grant read", - currentAccessLevel: "", - grantAccessLevel: string(jujuparams.OfferReadAccess), - expectedAccessLevel: "read", - }, { - about: "user has read access - grant admin", - currentAccessLevel: "read", - grantAccessLevel: string(jujuparams.OfferAdminAccess), - expectedAccessLevel: "admin", - }, { - about: "user has read access - grant consume", - currentAccessLevel: "read", - grantAccessLevel: string(jujuparams.OfferConsumeAccess), - expectedAccessLevel: "consume", - }, { - about: "user has read access - grant read", - currentAccessLevel: "read", - grantAccessLevel: string(jujuparams.OfferReadAccess), - expectedAccessLevel: "read", - }, { - about: "user has consume access - grant admin", - currentAccessLevel: "consume", - grantAccessLevel: string(jujuparams.OfferAdminAccess), - expectedAccessLevel: "admin", - }, { - about: "user has consume access - grant consume", - currentAccessLevel: "consume", - grantAccessLevel: string(jujuparams.OfferConsumeAccess), - expectedAccessLevel: "consume", - }, { - about: "user has consume access - grant read", - currentAccessLevel: "consume", - grantAccessLevel: string(jujuparams.OfferReadAccess), - expectedAccessLevel: "consume", - }, { - about: "user has admin access - grant admin", - currentAccessLevel: "admin", - grantAccessLevel: string(jujuparams.OfferAdminAccess), - expectedAccessLevel: "admin", - }, { - about: "user has admin access - grant consume", - currentAccessLevel: "admin", - grantAccessLevel: string(jujuparams.OfferConsumeAccess), - expectedAccessLevel: "admin", - }, { - about: "user has admin access - grant read", - currentAccessLevel: "admin", - grantAccessLevel: string(jujuparams.OfferReadAccess), - expectedAccessLevel: "admin", - }} - - for _, test := range tests { - c.Run(test.about, func(c *qt.C) { - level := jimm.DetermineAccessLevelAfterGrant(test.currentAccessLevel, test.grantAccessLevel) - c.Assert(level, qt.Equals, test.expectedAccessLevel) - }) - } -} - func TestDestroyOffer(t *testing.T) { c := qt.New(t) @@ -2202,16 +1790,6 @@ func TestListApplicationOffers(t *testing.T) { Interface: "unknown", Limit: 1, }}, - Users: []jujuparams.OfferUserDetails{{ - UserName: "alice@canonical.com", - Access: "admin", - }, { - UserName: "eve@canonical.com", - Access: "read", - }, { - UserName: "bob@canonical.com", - Access: "consume", - }}, }, ApplicationName: "application-1", CharmURL: "charm-1", @@ -2234,16 +1812,6 @@ func TestListApplicationOffers(t *testing.T) { Interface: "unknown", Limit: 1, }}, - Users: []jujuparams.OfferUserDetails{{ - UserName: "alice@canonical.com", - Access: "admin", - }, { - UserName: "eve@canonical.com", - Access: "read", - }, { - UserName: "bob@canonical.com", - Access: "consume", - }}, }, ApplicationName: "application-2", CharmURL: "charm-2", @@ -2268,16 +1836,6 @@ func TestListApplicationOffers(t *testing.T) { Interface: "unknown", Limit: 1, }}, - Users: []jujuparams.OfferUserDetails{{ - UserName: "alice@canonical.com", - Access: "admin", - }, { - UserName: "eve@canonical.com", - Access: "read", - }, { - UserName: "bob@canonical.com", - Access: "consume", - }}, }, ApplicationName: "application-3", CharmURL: "charm-3", @@ -2374,7 +1932,7 @@ func TestListApplicationOffers(t *testing.T) { Access: "admin", }, { UserName: "bob@canonical.com", - Access: "consume", + Access: "admin", }, { UserName: "eve@canonical.com", Access: "read", @@ -2406,7 +1964,7 @@ func TestListApplicationOffers(t *testing.T) { Access: "admin", }, { UserName: "bob@canonical.com", - Access: "consume", + Access: "admin", }, { UserName: "eve@canonical.com", Access: "read", diff --git a/internal/jimm/cloud.go b/internal/jimm/cloud.go index 56acd01d1..d0f8a1178 100644 --- a/internal/jimm/cloud.go +++ b/internal/jimm/cloud.go @@ -9,23 +9,17 @@ import ( jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/juju/zaputil" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm/permissions" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" ) -// GetUserCloudAccess returns users access level for the specified cloud. -func (j *JIMM) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) { - accessLevel := user.GetCloudAccess(ctx, cloud) - return ToCloudAccessString(accessLevel), nil -} - // GetCloud retrieves the cloud for the given cloud tag. If the cloud // cannot be found then an error with the code CodeNotFound is // returned. If the user does not have permission to view the cloud then an @@ -42,7 +36,7 @@ func (j *JIMM) GetCloud(ctx context.Context, user *openfga.User, tag names.Cloud return cl, errors.E(op, err) } - accessLevel, err := j.GetUserCloudAccess(ctx, user, tag) + accessLevel, err := j.permissionManager.GetUserCloudAccess(ctx, user, tag) if err != nil { return dbmodel.Cloud{}, errors.E(op, err) } @@ -73,7 +67,7 @@ func (j *JIMM) ForEachUserCloud(ctx context.Context, user *openfga.User, f func( return errors.E(op, err, "cannot load clouds") } for _, cloud := range clouds { - userAccess := ToCloudAccessString(user.GetCloudAccess(ctx, cloud.ResourceTag())) + userAccess := permissions.ToCloudAccessString(user.GetCloudAccess(ctx, cloud.ResourceTag())) if userAccess == "" { // If user does not have access to the cloud, // we skip this cloud. @@ -421,146 +415,6 @@ func (j *JIMM) doCloudAdmin(ctx context.Context, user *openfga.User, ct names.Cl return nil } -// GrantCloudAccess grants the given access level on the given cloud to the -// given user. If the cloud is not found then an error with the code -// CodeNotFound is returned. If the authenticated user does not have admin -// access to the cloud then an error with the code CodeUnauthorized is -// returned. -func (j *JIMM) GrantCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { - const op = errors.Op("jimm.GrantCloudAccess") - - targetRelation, err := ToCloudRelation(access) - if err != nil { - zapctx.Debug( - ctx, - "failed to recognize given access", - zaputil.Error(err), - zap.String("access", string(access)), - ) - return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) - } - - err = j.doCloudAdmin(ctx, user, ct, func(_ *dbmodel.Cloud, _ API) error { - targetUser := &dbmodel.Identity{} - targetUser.SetTag(ut) - if err := j.Database.GetIdentity(ctx, targetUser); err != nil { - return err - } - targetOfgaUser := openfga.NewUser(targetUser, j.OpenFGAClient) - - currentRelation := targetOfgaUser.GetCloudAccess(ctx, ct) - switch targetRelation { - case ofganames.CanAddModelRelation: - switch currentRelation { - case ofganames.NoRelation: - break - default: - return nil - } - case ofganames.AdministratorRelation: - switch currentRelation { - case ofganames.NoRelation, ofganames.CanAddModelRelation: - break - default: - return nil - } - } - - if err := targetOfgaUser.SetCloudAccess(ctx, ct, targetRelation); err != nil { - return errors.E(err, op, "failed to set cloud access") - } - return nil - }) - - if err != nil { - zapctx.Error( - ctx, - "failed to grant cloud access", - zaputil.Error(err), - zap.String("targetUser", string(ut.Id())), - zap.String("cloud", string(ct.Id())), - zap.String("access", string(access)), - ) - return errors.E(op, err) - } - return nil -} - -// RevokeCloudAccess revokes the given access level on the given cloud from -// the given user. If the cloud is not found then an error with the code -// CodeNotFound is returned. If the authenticated user does not have admin -// access to the cloud then an error with the code CodeUnauthorized is -// returned. -func (j *JIMM) RevokeCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { - const op = errors.Op("jimm.RevokeCloudAccess") - - targetRelation, err := ToCloudRelation(access) - if err != nil { - zapctx.Debug( - ctx, - "failed to recognize given access", - zaputil.Error(err), - zap.String("access", string(access)), - ) - return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) - } - - err = j.doCloudAdmin(ctx, user, ct, func(_ *dbmodel.Cloud, _ API) error { - targetUser := &dbmodel.Identity{} - targetUser.SetTag(ut) - if err := j.Database.GetIdentity(ctx, targetUser); err != nil { - return err - } - targetOfgaUser := openfga.NewUser(targetUser, j.OpenFGAClient) - - currentRelation := targetOfgaUser.GetCloudAccess(ctx, ct) - - var relationsToRevoke []openfga.Relation - switch targetRelation { - case ofganames.CanAddModelRelation: - switch currentRelation { - case ofganames.NoRelation: - return nil - default: - // If we're revoking "add-model" access, in addition to the "add-model" relation, we should also revoke the - // "admin" relation. That's because having an "admin" relation indirectly grants the "add-model" permission - // to the user. - relationsToRevoke = []openfga.Relation{ - ofganames.CanAddModelRelation, - ofganames.AdministratorRelation, - } - } - case ofganames.AdministratorRelation: - switch currentRelation { - case ofganames.NoRelation, ofganames.CanAddModelRelation: - return nil - default: - relationsToRevoke = []openfga.Relation{ - ofganames.AdministratorRelation, - } - } - } - - if err := targetOfgaUser.UnsetCloudAccess(ctx, ct, relationsToRevoke...); err != nil { - return errors.E(err, op, "failed to unset cloud access") - } - return nil - }) - - if err != nil { - zapctx.Error( - ctx, - "failed to revoke cloud access", - zaputil.Error(err), - zap.String("targetUser", string(ut.Id())), - zap.String("cloud", string(ct.Id())), - zap.String("access", string(access)), - ) - return errors.E(op, err) - } - return nil -} - // RemoveCloud removes the given cloud from JAAS If the cloud is not found // then an error with the code CodeNotFound is returned. If the // authenticated user does not have admin access to the cloud then an error @@ -607,7 +461,7 @@ func (j *JIMM) UpdateCloud(ctx context.Context, user *openfga.User, ct names.Clo if err := j.Database.GetCloud(ctx, &c); err != nil { return errors.E(op, err) } - cloudAccess, err := j.GetUserCloudAccess(ctx, user, c.ResourceTag()) + cloudAccess, err := j.permissionManager.GetUserCloudAccess(ctx, user, c.ResourceTag()) if err != nil { return errors.E(op, err) } diff --git a/internal/jimm/cloud_test.go b/internal/jimm/cloud_test.go index 716efc9da..e80ff0b0f 100644 --- a/internal/jimm/cloud_test.go +++ b/internal/jimm/cloud_test.go @@ -975,473 +975,6 @@ func TestAddCloudToController(t *testing.T) { } } -const grantCloudAccessTestEnv = `clouds: -- name: test-cloud - type: test-provider - regions: - - name: test-cloud-region -- name: test - type: kubernetes - host-cloud-region: test-cloud/test-cloud-region - regions: - - name: default - - name: region2 - users: - - user: alice@canonical.com - access: admin -controllers: -- name: controller-1 - uuid: 00000001-0000-0000-0000-000000000001 - cloud: test-cloud - region: test-cloud-region - cloud-regions: - - cloud: test-cloud - region: test-cloud-region - priority: 10 - - cloud: test - region: default - priority: 1 - - cloud: test - region: region2 - priority: 1 -` - -var grantCloudAccessTests = []struct { - name string - env string - dialError error - username string - cloud string - targetUsername string - access string - expectRelations []openfga.Tuple - expectError string - expectErrorCode errors.Code -}{{ - name: "CloudNotFound", - username: "alice@canonical.com", - cloud: "test2", - targetUsername: "bob@canonical.com", - access: "add-model", - expectError: `cloud "test2" not found`, - expectErrorCode: errors.CodeNotFound, -}, { - name: "Admin grants admin access", - env: grantCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "Admin grants add-model access", - env: grantCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "add-model", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "UserNotAuthorized", - env: grantCloudAccessTestEnv, - username: "charlie@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "add-model", - expectError: `unauthorized`, - expectErrorCode: errors.CodeUnauthorized, -}, { - name: "DialError", - env: grantCloudAccessTestEnv, - dialError: errors.E("test dial error"), - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "add-model", - expectError: `test dial error`, -}, { - name: "unknown access", - env: grantCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "some-unknown-access", - expectError: `failed to recognize given access: "some-unknown-access"`, -}} - -func TestGrantCloudAccess(t *testing.T) { - c := qt.New(t) - - for _, t := range grantCloudAccessTests { - tt := t - c.Run(tt.name, func(c *qt.C) { - ctx := context.Background() - - env := jimmtest.ParseEnvironment(c, tt.env) - dialer := &jimmtest.Dialer{ - API: &jimmtest.API{}, - Err: tt.dialError, - } - - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - Dialer: dialer, - }) - - env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) - - dbUser := env.User(tt.username).DBObject(c, j.Database) - user := openfga.NewUser(&dbUser, j.OpenFGAClient) - - err := j.GrantCloudAccess(ctx, user, names.NewCloudTag(tt.cloud), names.NewUserTag(tt.targetUsername), tt.access) - c.Assert(dialer.IsClosed(), qt.Equals, true) - if tt.expectError != "" { - c.Check(err, qt.ErrorMatches, tt.expectError) - if tt.expectErrorCode != "" { - c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) - } - return - } - c.Assert(err, qt.IsNil) - for _, tuple := range tt.expectRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after granting")) - } - }) - } -} - -const revokeCloudAccessTestEnv = `clouds: -- name: test-cloud - type: test-provider - regions: - - name: test-cloud-region - users: - - user: daphne@canonical.com - access: admin -- name: test - type: kubernetes - host-cloud-region: test-cloud/test-cloud-region - regions: - - name: default - users: - - user: alice@canonical.com - access: admin - - user: bob@canonical.com - access: admin - - user: charlie@canonical.com - access: add-model -controllers: -- name: controller-1 - uuid: 00000001-0000-0000-0000-000000000001 - cloud: test-cloud - region: test-cloud-region - cloud-regions: - - cloud: test-cloud - region: test-cloud-region - priority: 10 - - cloud: test - region: default - priority: 1 -` - -var revokeCloudAccessTests = []struct { - name string - env string - dialError error - username string - cloud string - targetUsername string - access string - extraInitialTuples []openfga.Tuple - expectRelations []openfga.Tuple - expectRemovedRelations []openfga.Tuple - expectError string - expectErrorCode errors.Code -}{{ - name: "CloudNotFound", - username: "alice@canonical.com", - cloud: "test2", - targetUsername: "bob@canonical.com", - access: "admin", - expectError: `cloud "test2" not found`, - expectErrorCode: errors.CodeNotFound, -}, { - name: "Admin revokes 'admin' from another admin", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "Admin revokes 'add-model' from another admin", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "add-model", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "Admin revokes 'add-model' from a user with 'add-model' access", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "charlie@canonical.com", - access: "add-model", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "Admin revokes 'add-model' from a user with no access", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "daphne@canonical.com", - access: "add-model", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "Admin revokes 'admin' from a user with no access", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "daphne@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "Admin revokes 'add-model' access from a user who has separate tuples for all accesses (add-model/admin)", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "charlie@canonical.com", - access: "add-model", - extraInitialTuples: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, - // No need to add the 'add-model' relation, because it's already there due to the environment setup. - }, - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "Admin revokes 'admin' access from a user who has separate tuples for all accesses (add-model/admin)", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "charlie@canonical.com", - access: "admin", - extraInitialTuples: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, - // No need to add the 'add-model' relation, because it's already there due to the environment setup. - }, - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.CanAddModelRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewCloudTag("test")), - }}, -}, { - name: "UserNotAuthorized", - env: revokeCloudAccessTestEnv, - username: "charlie@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "add-model", - expectError: `unauthorized`, - expectErrorCode: errors.CodeUnauthorized, -}, { - name: "DialError", - env: revokeCloudAccessTestEnv, - dialError: errors.E("test dial error"), - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "add-model", - expectError: `test dial error`, -}, { - name: "unknown access", - env: revokeCloudAccessTestEnv, - username: "alice@canonical.com", - cloud: "test", - targetUsername: "bob@canonical.com", - access: "some-unknown-access", - expectError: `failed to recognize given access: "some-unknown-access"`, -}} - -//nolint:gocognit -func TestRevokeCloudAccess(t *testing.T) { - c := qt.New(t) - - for _, t := range revokeCloudAccessTests { - tt := t - c.Run(tt.name, func(c *qt.C) { - ctx := context.Background() - - env := jimmtest.ParseEnvironment(c, tt.env) - dialer := &jimmtest.Dialer{ - API: &jimmtest.API{}, - Err: tt.dialError, - } - - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - Dialer: dialer, - }) - - env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) - - if len(tt.extraInitialTuples) > 0 { - err := j.OpenFGAClient.AddRelation(ctx, tt.extraInitialTuples...) - c.Assert(err, qt.IsNil) - } - - if tt.expectRemovedRelations != nil { - for _, tuple := range tt.expectRemovedRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist before revoking")) - } - } - - dbUser := env.User(tt.username).DBObject(c, j.Database) - user := openfga.NewUser(&dbUser, j.OpenFGAClient) - - err := j.RevokeCloudAccess(ctx, user, names.NewCloudTag(tt.cloud), names.NewUserTag(tt.targetUsername), tt.access) - c.Assert(dialer.IsClosed(), qt.Equals, true) - if tt.expectError != "" { - c.Check(err, qt.ErrorMatches, tt.expectError) - if tt.expectErrorCode != "" { - c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) - } - return - } - c.Assert(err, qt.IsNil) - if tt.expectRemovedRelations != nil { - for _, tuple := range tt.expectRemovedRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsFalse, qt.Commentf("expected the tuple to be removed after revoking")) - } - } - if tt.expectRelations != nil { - for _, tuple := range tt.expectRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after revoking")) - } - } - }) - } -} - const removeCloudTestEnv = `clouds: - name: test-cloud type: test-provider diff --git a/internal/jimm/controller.go b/internal/jimm/controller.go index feb54eeb3..c4f3197cf 100644 --- a/internal/jimm/controller.go +++ b/internal/jimm/controller.go @@ -348,51 +348,6 @@ func (j *JIMM) EarliestControllerVersion(ctx context.Context) (version.Number, e return *v, nil } -// GetJimmControllerAccess returns the JIMM controller access level for the -// requested user. -func (j *JIMM) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) { - const op = errors.Op("jimm.GetJIMMControllerAccess") - - // If the authenticated user is requesting the access level - // for him/her-self then we return that - either the user - // is a JIMM admin (aka "superuser"), or they have a "login" - // access level. - if user.Name == tag.Id() { - if user.JimmAdmin { - return "superuser", nil - } - return "login", nil - } - - // Only JIMM administrators are allowed to see the access - // level of somebody else. - if !user.JimmAdmin { - return "", errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - var targetUser dbmodel.Identity - targetUser.SetTag(tag) - targetUserTag := openfga.NewUser(&targetUser, j.OpenFGAClient) - - // Check if the user is jimm administrator. - isAdmin, err := openfga.IsAdministrator(ctx, targetUserTag, j.ResourceTag()) - if err != nil { - zapctx.Error(ctx, "failed to check access rights", zap.Error(err)) - return "", errors.E(op, err) - } - if isAdmin { - return "superuser", nil - } - - return "login", nil -} - -// GetUserControllerAccess returns the user's level of access to the desired controller. -func (j *JIMM) GetUserControllerAccess(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) { - accessLevel := user.GetControllerAccess(ctx, controller) - return ToControllerAccessString(accessLevel), nil -} - type modelImporter struct { jimm *JIMM model dbmodel.Model diff --git a/internal/jimm/controller_test.go b/internal/jimm/controller_test.go index 474c92176..294aadc88 100644 --- a/internal/jimm/controller_test.go +++ b/internal/jimm/controller_test.go @@ -1118,52 +1118,6 @@ func TestUpdateMigratedModel(t *testing.T) { } } -const testGetControllerAccessEnv = ` -users: -- username: alice@canonical.com - display-name: Alice - controller-access: superuser -- username: bob@canonical.com - display-name: Bob - controller-access: login -` - -func TestGetControllerAccess(t *testing.T) { - c := qt.New(t) - - j := jimmtest.NewJIMM(c, nil) - - ctx := context.Background() - - env := jimmtest.ParseEnvironment(c, testGetControllerAccessEnv) - env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) - - dbUser := env.User("alice@canonical.com").DBObject(c, j.Database) - alice := openfga.NewUser(&dbUser, j.OpenFGAClient) - alice.JimmAdmin = true - - access, err := j.GetJimmControllerAccess(ctx, alice, names.NewUserTag("alice@canonical.com")) - c.Assert(err, qt.IsNil) - c.Check(access, qt.Equals, "superuser") - - access, err = j.GetJimmControllerAccess(ctx, alice, names.NewUserTag("bob@canonical.com")) - c.Assert(err, qt.IsNil) - c.Check(access, qt.Equals, "login") - - access, err = j.GetJimmControllerAccess(ctx, alice, names.NewUserTag("charlie@canonical.com")) - c.Assert(err, qt.IsNil) - c.Check(access, qt.Equals, "login") - - dbUser = env.User("bob@canonical.com").DBObject(c, j.Database) - alice = openfga.NewUser(&dbUser, j.OpenFGAClient) - access, err = j.GetJimmControllerAccess(ctx, alice, names.NewUserTag("bob@canonical.com")) - c.Assert(err, qt.IsNil) - c.Check(access, qt.Equals, "login") - - _, err = j.GetJimmControllerAccess(ctx, alice, names.NewUserTag("alice@canonical.com")) - c.Assert(err, qt.ErrorMatches, "unauthorized") -} - const testInitiateMigrationEnv = `clouds: - name: test-cloud type: test diff --git a/internal/jimm/export_test.go b/internal/jimm/export_test.go index 251233fa8..b56f0ada0 100644 --- a/internal/jimm/export_test.go +++ b/internal/jimm/export_test.go @@ -11,17 +11,14 @@ import ( "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/openfga" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" ) var ( - DetermineAccessLevelAfterGrant = determineAccessLevelAfterGrant - PollDuration = pollDuration - CalculateNextPollDuration = calculateNextPollDuration - NewControllerClient = &newControllerClient - FillMigrationTarget = fillMigrationTarget - InitiateMigration = &initiateMigration - ResolveTag = resolveTag + PollDuration = pollDuration + CalculateNextPollDuration = calculateNextPollDuration + NewControllerClient = &newControllerClient + FillMigrationTarget = fillMigrationTarget + InitiateMigration = &initiateMigration ) func NewWatcherWithControllerUnavailableChan(db *db.Database, dialer Dialer, pubsub Publisher, testChannel chan error) *Watcher { @@ -46,10 +43,6 @@ func (j *JIMM) ListApplicationOfferUsers(ctx context.Context, offer names.Applic return j.listApplicationOfferUsers(ctx, offer, user, adminAccess) } -func (j *JIMM) ParseAndValidateTag(ctx context.Context, key string) (*ofganames.Tag, error) { - return j.parseAndValidateTag(ctx, key) -} - func (j *JIMM) EveryoneUser() *openfga.User { return j.everyoneUser() } diff --git a/internal/jimm/jimm.go b/internal/jimm/jimm.go index 58e5ad2b3..0b743c9e4 100644 --- a/internal/jimm/jimm.go +++ b/internal/jimm/jimm.go @@ -32,12 +32,16 @@ import ( "github.com/canonical/jimm/v3/internal/jimm/credentials" "github.com/canonical/jimm/v3/internal/jimm/group" "github.com/canonical/jimm/v3/internal/jimm/identity" + "github.com/canonical/jimm/v3/internal/jimm/jujuauth" "github.com/canonical/jimm/v3/internal/jimm/login" + "github.com/canonical/jimm/v3/internal/jimm/permissions" "github.com/canonical/jimm/v3/internal/jimm/role" "github.com/canonical/jimm/v3/internal/jimmjwx" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/pubsub" + apiparams "github.com/canonical/jimm/v3/pkg/api/params" + jimmnames "github.com/canonical/jimm/v3/pkg/names" ) var ( @@ -168,6 +172,54 @@ type LoginManager interface { UserLogin(ctx context.Context, identity string) (*openfga.User, error) } +// PermissionManager provides a way to manage permissions within JIMM. +type PermissionManager interface { + // These methods handle generic permission management through manipulation of OpenFGA tuples. + + // AddRelation creates the provided slice of tuples. + AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error + // RemoveRelation removes the provided slice of tuples. + RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error + // CheckRelation checks whether the provided tuple provides access. + CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (bool, error) + // ListRelationshipTuples lists a page of tuples based on the provided tuple constraints. + ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) + // ListObjectRelations lists all the tuples that an object has a direct relation with. + ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) + + // GetJimmControllerAccess returns the user's level of access to JIMM. + GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) + // GetUserCloudAccess returns the user's level of access to a cloud. + GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) + // GetUserModelAccess returns the user's level of access to a model. + GetUserModelAccess(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) + + // GrantAuditLogAccess grants a user access to read audit logs. + GrantAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error + // GrantCloudAccess grants the user the specified access to a cloud. + GrantCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error + // GrantModelAccess grants the user the specified access to a model. + GrantModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error + // GrantOfferAccess grants the user the specified access to an offer. + GrantOfferAccess(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error + // GrantServiceAccountAccess grants a user access to manage a service account. + GrantServiceAccountAccess(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error + + // RevokeAuditLogAccess revokes a user's access to read audit logs. + RevokeAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error + // RevokeCloudAccess revokes the specified access to a cloud. + RevokeCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error + // RevokeModelAccess revokes the specified access to a model. + RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error + // RevokeOfferAccess revokes the specified access to an offer. + RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) + + // OpenFGACleanup removes tuples that are no longer valid. + OpenFGACleanup(ctx context.Context) error + // ToJAASTag converts a tag used in OpenFGA authorization model to a tag used in JAAS. + ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) +} + // Parameters holds the services and static fields passed to the jimm.New() constructor. // You can provide mock implementations of certain services where necessary for dependency injection. type Parameters struct { @@ -287,6 +339,14 @@ func New(p Parameters) (*JIMM, error) { } j.loginManager = loginManager + permissionManager, err := permissions.NewManager(j.Database, j.OpenFGAClient, j.UUID, j.ResourceTag()) + if err != nil { + return nil, err + } + j.permissionManager = permissionManager + + j.jujuAuthFactory = jujuauth.NewFactory(j.Database, j.JWTService, permissionManager) + return j, nil } @@ -308,6 +368,10 @@ type JIMM struct { // loginManager provides a means to authenticate and login/create users/identities within JIMM. loginManager LoginManager + + permissionManager PermissionManager + + jujuAuthFactory *jujuauth.Factory } // ResourceTag returns JIMM's controller tag stating its UUID. @@ -340,6 +404,18 @@ func (j *JIMM) LoginManager() LoginManager { return j.loginManager } +// PermissionManager returns a manager that enables permission checks and +// permissions grants/revocations. +func (j *JIMM) PermissionManager() PermissionManager { + return j.permissionManager +} + +// NewJujuAuthenticator returns a new token generator for authenticating +// requests to a Juju controller. +func (j *JIMM) NewJujuAuthenticator() jujuauth.TokenGenerator { + return j.jujuAuthFactory.New() +} + type permission struct { resource string relation string diff --git a/internal/jimm/jujuauth/factory.go b/internal/jimm/jujuauth/factory.go new file mode 100644 index 000000000..156eb4897 --- /dev/null +++ b/internal/jimm/jujuauth/factory.go @@ -0,0 +1,26 @@ +// Copyright 2025 Canonical. + +package jujuauth + +// Factory holds the necessary components for producing new stateful +// Juju authenticator objects. Because these objects are +// stateful, it is expected that a new one is used for each connection. +type Factory struct { + db GeneratorDatabase + jwtService JWTService + accessChecker GeneratorAccessChecker +} + +// NewFactory returns a new factory object. +func NewFactory(db GeneratorDatabase, jwtService JWTService, accessChecker GeneratorAccessChecker) *Factory { + return &Factory{ + db: db, + jwtService: jwtService, + accessChecker: accessChecker, + } +} + +// New returns a new Juju token generator. +func (f *Factory) New() TokenGenerator { + return newTokenGenerator(f.db, f.accessChecker, f.jwtService) +} diff --git a/internal/jimm/jujuauth/jwtgenerator.go b/internal/jimm/jujuauth/jwtgenerator.go index 7924d2ce8..5c49fc832 100644 --- a/internal/jimm/jujuauth/jwtgenerator.go +++ b/internal/jimm/jujuauth/jwtgenerator.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. // Package jujuauth generates JWT tokens to // authenticate and authorize messages to Juju controllers. @@ -56,8 +56,8 @@ type TokenGenerator struct { callCount int } -// New returns a new JWTGenerator. -func New(database GeneratorDatabase, accessChecker GeneratorAccessChecker, jwtService JWTService) TokenGenerator { +// newTokenGenerator returns a new TokenGenerator. +func newTokenGenerator(database GeneratorDatabase, accessChecker GeneratorAccessChecker, jwtService JWTService) TokenGenerator { return TokenGenerator{ database: database, accessChecker: accessChecker, diff --git a/internal/jimm/jujuauth/jwtgenerator_test.go b/internal/jimm/jujuauth/jwtgenerator_test.go index f04519486..23c61bf4e 100644 --- a/internal/jimm/jujuauth/jwtgenerator_test.go +++ b/internal/jimm/jujuauth/jwtgenerator_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. package jujuauth_test @@ -238,7 +238,8 @@ func TestJWTGeneratorMakeLoginToken(t *testing.T) { }} for _, test := range tests { - generator := jujuauth.New(test.database, test.accessChecker, test.jwtService) + authFactory := jujuauth.NewFactory(test.database, test.jwtService, test.accessChecker) + generator := authFactory.New() generator.SetTags(mt, ct) i, err := dbmodel.NewIdentity(test.username) @@ -311,7 +312,7 @@ func TestJWTGeneratorMakeToken(t *testing.T) { }} for _, test := range tests { - generator := jujuauth.New( + authFactory := jujuauth.NewFactory( &testDatabase{ ctl: dbmodel.Controller{ CloudRegions: []dbmodel.CloudRegionControllerPriority{{ @@ -323,6 +324,7 @@ func TestJWTGeneratorMakeToken(t *testing.T) { }}, }, }, + test.jwtService, &testAccessChecker{ modelAccess: map[string]string{ mt.String(): "admin", @@ -336,8 +338,8 @@ func TestJWTGeneratorMakeToken(t *testing.T) { permissions: test.checkPermissions, permissionCheckErr: test.checkPermissionsError, }, - test.jwtService, ) + generator := authFactory.New() generator.SetTags(mt, ct) i, err := dbmodel.NewIdentity("eve@canonical.com") diff --git a/internal/jimm/login/login.go b/internal/jimm/login/login.go index f16a27977..550209fd0 100644 --- a/internal/jimm/login/login.go +++ b/internal/jimm/login/login.go @@ -96,6 +96,9 @@ func NewLoginManager(store *db.Database, authSvc *openfga.OFGAClient, oAuthAuthe if oAuthAuthenticator == nil { return nil, errors.E("oauth service cannot be nil") } + if jimmTag.Id() == "" { + return nil, errors.E("invalid jimm controller tag") + } return &loginManager{store, authSvc, oAuthAuthenticator, jimmTag}, nil } diff --git a/internal/jimm/model.go b/internal/jimm/model.go index 482086160..76f98f1ee 100644 --- a/internal/jimm/model.go +++ b/internal/jimm/model.go @@ -23,6 +23,7 @@ import ( "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm/permissions" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" ) @@ -851,12 +852,12 @@ func (j *JIMM) mergeModelInfo(ctx context.Context, user *openfga.User, modelInfo // access privilege, we want to make sure the user has not // already been recorded with a higher access level. if _, ok := userAccess[u.Name]; !ok { - userAccess[u.Name] = ToModelAccessString(relation) + userAccess[u.Name] = permissions.ToModelAccessString(relation) } } } - modelAccess, err := j.GetUserModelAccess(ctx, user, jimmModel.ResourceTag()) + modelAccess, err := j.permissionManager.GetUserModelAccess(ctx, user, jimmModel.ResourceTag()) if err != nil { return nil, errors.E(op, err) } @@ -925,7 +926,7 @@ func (j *JIMM) ForEachUserModel(ctx context.Context, user *openfga.User, f func( err := j.Database.ForEachModel(ctx, func(m *dbmodel.Model) error { model := *m - access, err := j.GetUserModelAccess(ctx, user, model.ResourceTag()) + access, err := j.permissionManager.GetUserModelAccess(ctx, user, model.ResourceTag()) if err != nil { return errors.E(op, err) } @@ -982,169 +983,6 @@ func (j *JIMM) ForEachModel(ctx context.Context, user *openfga.User, f func(*dbm } } -// GrantModelAccess grants the given access level on the given model to -// the given user. If the model is not found then an error with the code -// CodeNotFound is returned. If the authenticated user does not have -// admin access to the model then an error with the code CodeUnauthorized -// is returned. -func (j *JIMM) GrantModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { - const op = errors.Op("jimm.GrantModelAccess") - zapctx.Info(ctx, string(op)) - - targetRelation, err := ToModelRelation(string(access)) - if err != nil { - zapctx.Debug( - ctx, - "failed to recognize given access", - zaputil.Error(err), - zap.String("access", string(access)), - ) - return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) - } - - err = j.doModelAdmin(ctx, user, mt, func(_ *dbmodel.Model, _ API) error { - targetUser := &dbmodel.Identity{} - targetUser.SetTag(ut) - if err := j.Database.GetIdentity(ctx, targetUser); err != nil { - return err - } - targetOfgaUser := openfga.NewUser(targetUser, j.OpenFGAClient) - - currentRelation := targetOfgaUser.GetModelAccess(ctx, mt) - switch targetRelation { - case ofganames.ReaderRelation: - switch currentRelation { - case ofganames.NoRelation: - break - default: - return nil - } - case ofganames.WriterRelation: - switch currentRelation { - case ofganames.NoRelation, ofganames.ReaderRelation: - break - default: - return nil - } - case ofganames.AdministratorRelation: - switch currentRelation { - case ofganames.NoRelation, ofganames.ReaderRelation, ofganames.WriterRelation: - break - default: - return nil - } - } - - if err := targetOfgaUser.SetModelAccess(ctx, mt, targetRelation); err != nil { - return errors.E(err, op, "failed to set model access") - } - return nil - }) - - if err != nil { - zapctx.Error( - ctx, - "failed to grant model access", - zaputil.Error(err), - zap.String("targetUser", string(ut.Id())), - zap.String("model", string(mt.Id())), - zap.String("access", string(access)), - ) - return errors.E(op, err) - } - return nil -} - -// RevokeModelAccess revokes the given access level on the given model from -// the given user. If the model is not found then an error with the code -// CodeNotFound is returned. If the authenticated user does not have admin -// access to the model, and is not attempting to revoke their own access, -// then an error with the code CodeUnauthorized is returned. -func (j *JIMM) RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { - const op = errors.Op("jimm.RevokeModelAccess") - zapctx.Info(ctx, string(op)) - - targetRelation, err := ToModelRelation(string(access)) - if err != nil { - zapctx.Debug( - ctx, - "failed to recognize given access", - zaputil.Error(err), - zap.String("access", string(access)), - ) - return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) - } - - requiredAccess := ofganames.AdministratorRelation - if user.Tag() == ut { - // If the user is attempting to revoke their own access. - requiredAccess = ofganames.ReaderRelation - } - - err = j.doModel(ctx, user, mt, requiredAccess, func(_ *dbmodel.Model, _ API) error { - targetUser := &dbmodel.Identity{} - targetUser.SetTag(ut) - if err := j.Database.GetIdentity(ctx, targetUser); err != nil { - return err - } - targetOfgaUser := openfga.NewUser(targetUser, j.OpenFGAClient) - - currentRelation := targetOfgaUser.GetModelAccess(ctx, mt) - - var relationsToRevoke []openfga.Relation - switch targetRelation { - case ofganames.ReaderRelation: - switch currentRelation { - case ofganames.NoRelation: - return nil - default: - relationsToRevoke = []openfga.Relation{ - ofganames.ReaderRelation, - ofganames.WriterRelation, - ofganames.AdministratorRelation, - } - } - case ofganames.WriterRelation: - switch currentRelation { - case ofganames.NoRelation, ofganames.ReaderRelation: - return nil - default: - relationsToRevoke = []openfga.Relation{ - ofganames.WriterRelation, - ofganames.AdministratorRelation, - } - } - case ofganames.AdministratorRelation: - switch currentRelation { - case ofganames.NoRelation, ofganames.ReaderRelation, ofganames.WriterRelation: - return nil - default: - relationsToRevoke = []openfga.Relation{ - ofganames.AdministratorRelation, - } - } - } - - if err := targetOfgaUser.UnsetModelAccess(ctx, mt, relationsToRevoke...); err != nil { - return errors.E(err, op, "failed to unset model access") - } - return nil - }) - - if err != nil { - zapctx.Error( - ctx, - "failed to revoke model access", - zaputil.Error(err), - zap.String("targetUser", string(ut.Id())), - zap.String("model", string(mt.Id())), - zap.String("access", string(access)), - ) - return errors.E(op, err) - } - return nil -} - // DestroyModel starts the process of destroying the given model. If the // given user is not a controller superuser or a model admin an error // with a code of CodeUnauthorized is returned. Any error returned from @@ -1257,12 +1095,6 @@ func (j *JIMM) doModelAdmin(ctx context.Context, user *openfga.User, mt names.Mo return j.doModel(ctx, user, mt, ofganames.AdministratorRelation, f) } -// GetUserModelAccess returns the access level a user has against a specific model. -func (j *JIMM) GetUserModelAccess(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) { - accessLevel := user.GetModelAccess(ctx, model) - return ToModelAccessString(accessLevel), nil -} - func (j *JIMM) doModel(ctx context.Context, user *openfga.User, mt names.ModelTag, requireRelation openfga.Relation, f func(*dbmodel.Model, API) error) error { const op = errors.Op("jimm.doModel") zapctx.Info(ctx, string(op)) diff --git a/internal/jimm/model_test.go b/internal/jimm/model_test.go index f6a27ed46..e87ff530e 100644 --- a/internal/jimm/model_test.go +++ b/internal/jimm/model_test.go @@ -26,7 +26,6 @@ import ( "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/openfga" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/testutils/jimmtest" ) @@ -2003,997 +2002,6 @@ func TestModelSummaries(t *testing.T) { } } -const grantModelAccessTestEnv = `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 - users: - - user: alice@canonical.com - access: admin - - user: charlie@canonical.com - access: write -` - -var grantModelAccessTests = []struct { - name string - env string - dialError error - username string - uuid string - targetUsername string - access string - expectRelations []openfga.Tuple - expectError string - expectErrorCode errors.Code -}{{ - name: "ModelNotFound", - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectError: `model not found`, - expectErrorCode: errors.CodeNotFound, -}, { - name: "Admin grants 'admin' access to a user with no access", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin grants 'write' access to a user with no access", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin grants 'read' access to a user with no access", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin grants 'write' access to a user who already has 'write' access", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin grants 'read' access to a user who already has 'write' access", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin grants 'admin' access to themselves", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "alice@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin grants 'write' access to themselves", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "alice@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin grants 'read' access to themselves", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "alice@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "UserNotAuthorized", - env: grantModelAccessTestEnv, - username: "charlie@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectError: `unauthorized`, - expectErrorCode: errors.CodeUnauthorized, -}, { - name: "DialError", - env: grantModelAccessTestEnv, - dialError: errors.E("test dial error"), - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectError: `test dial error`, -}, { - name: "unknown access", - env: grantModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "some-unknown-access", - expectError: `failed to recognize given access: "some-unknown-access"`, -}} - -func TestGrantModelAccess(t *testing.T) { - c := qt.New(t) - - for _, t := range grantModelAccessTests { - tt := t - c.Run(tt.name, func(c *qt.C) { - ctx := context.Background() - - dialer := &jimmtest.Dialer{ - API: &jimmtest.API{}, - Err: tt.dialError, - } - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - Dialer: dialer, - }) - - env := jimmtest.ParseEnvironment(c, tt.env) - env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) - - dbUser := env.User(tt.username).DBObject(c, j.Database) - user := openfga.NewUser(&dbUser, j.OpenFGAClient) - - err := j.GrantModelAccess(ctx, user, names.NewModelTag(tt.uuid), names.NewUserTag(tt.targetUsername), jujuparams.UserAccessPermission(tt.access)) - c.Assert(dialer.IsClosed(), qt.IsTrue) - if tt.expectError != "" { - c.Check(err, qt.ErrorMatches, tt.expectError) - if tt.expectErrorCode != "" { - c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) - } - return - } - c.Assert(err, qt.IsNil) - for _, tuple := range tt.expectRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after granting")) - } - }) - } -} - -const revokeModelAccessTestEnv = `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 - users: - - user: alice@canonical.com - access: admin - - user: bob@canonical.com - access: admin - - user: charlie@canonical.com - access: write - - user: daphne@canonical.com - access: read -- 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 - users: - - user: alice@canonical.com - access: admin - - user: earl@canonical.com - access: admin -` - -var revokeModelAccessTests = []struct { - name string - env string - dialError error - username string - uuid string - targetUsername string - access string - extraInitialTuples []openfga.Tuple - expectRelations []openfga.Tuple - expectRemovedRelations []openfga.Tuple - expectError string - expectErrorCode errors.Code -}{{ - name: "ModelNotFound", - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectError: `model not found`, - expectErrorCode: errors.CodeNotFound, -}, { - name: "Admin revokes 'admin' access from another admin", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'write' access from another admin", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'read' access from another admin", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'admin' access from a user who has 'write' access", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'write' access from a user who has 'write' access", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'read' access from a user who has 'write' access", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'admin' access from a user who has 'read' access", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'write' access from a user who has 'read' access", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'read' access from a user who has 'read' access", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'admin' access from themselves", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "alice@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'write' access from themselves", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "alice@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'read' access from themselves", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "alice@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Writer revokes 'admin' access from themselves", - env: revokeModelAccessTestEnv, - username: "charlie@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Writer revokes 'write' access from themselves", - env: revokeModelAccessTestEnv, - username: "charlie@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Writer revokes 'read' access from themselves", - env: revokeModelAccessTestEnv, - username: "charlie@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "charlie@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Reader revokes 'admin' access from themselves", - env: revokeModelAccessTestEnv, - username: "daphne@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "admin", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Reader revokes 'write' access from themselves", - env: revokeModelAccessTestEnv, - username: "daphne@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "write", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Reader revokes 'read' access from themselves", - env: revokeModelAccessTestEnv, - username: "daphne@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "read", - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'admin' access from a user who has separate tuples for all accesses (read/write/admin)", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "admin", - extraInitialTuples: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, - // No need to add the 'read' relation, because it's already there due to the environment setup. - }, - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'write' access from a user who has separate tuples for all accesses (read/write/admin)", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "write", - extraInitialTuples: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, - // No need to add the 'read' relation, because it's already there due to the environment setup. - }, - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "Admin revokes 'read' access from a user who has separate tuples for all accesses (read/write/admin)", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "daphne@canonical.com", - access: "read", - extraInitialTuples: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, - // No need to add the 'read' relation, because it's already there due to the environment setup. - }, - expectRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, - expectRemovedRelations: []openfga.Tuple{{ - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.WriterRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }, { - Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), - Relation: ofganames.ReaderRelation, - Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), - }}, -}, { - name: "UserNotAuthorized", - env: revokeModelAccessTestEnv, - username: "charlie@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectError: `unauthorized`, - expectErrorCode: errors.CodeUnauthorized, -}, { - name: "DialError", - env: revokeModelAccessTestEnv, - dialError: errors.E("test dial error"), - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "write", - expectError: `test dial error`, -}, { - name: "unknown access", - env: revokeModelAccessTestEnv, - username: "alice@canonical.com", - uuid: "00000002-0000-0000-0000-000000000001", - targetUsername: "bob@canonical.com", - access: "some-unknown-access", - expectError: `failed to recognize given access: "some-unknown-access"`, -}} - -//nolint:gocognit -func TestRevokeModelAccess(t *testing.T) { - c := qt.New(t) - - for _, t := range revokeModelAccessTests { - tt := t - c.Run(tt.name, func(c *qt.C) { - ctx := context.Background() - - dialer := &jimmtest.Dialer{ - API: &jimmtest.API{}, - Err: tt.dialError, - } - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - Dialer: dialer, - }) - - env := jimmtest.ParseEnvironment(c, tt.env) - env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) - - if len(tt.extraInitialTuples) > 0 { - err := j.OpenFGAClient.AddRelation(ctx, tt.extraInitialTuples...) - c.Assert(err, qt.IsNil) - } - - if tt.expectRemovedRelations != nil { - for _, tuple := range tt.expectRemovedRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist before revoking")) - } - } - - dbUser := env.User(tt.username).DBObject(c, j.Database) - user := openfga.NewUser(&dbUser, j.OpenFGAClient) - - err := j.RevokeModelAccess(ctx, user, names.NewModelTag(tt.uuid), names.NewUserTag(tt.targetUsername), jujuparams.UserAccessPermission(tt.access)) - c.Assert(dialer.IsClosed(), qt.IsTrue) - if tt.expectError != "" { - c.Check(err, qt.ErrorMatches, tt.expectError) - if tt.expectErrorCode != "" { - c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) - } - return - } - c.Assert(err, qt.IsNil) - if tt.expectRemovedRelations != nil { - for _, tuple := range tt.expectRemovedRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsFalse, qt.Commentf("expected the tuple to be removed after revoking")) - } - } - if tt.expectRelations != nil { - for _, tuple := range tt.expectRelations { - value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after revoking")) - } - } - }) - } -} - const destroyModelTestEnv = `clouds: - name: test-cloud type: test-provider diff --git a/internal/jimm/permissions/access.go b/internal/jimm/permissions/access.go new file mode 100644 index 000000000..8aa244634 --- /dev/null +++ b/internal/jimm/permissions/access.go @@ -0,0 +1,799 @@ +// Copyright 2025 Canonical. + +package permissions + +import ( + "context" + "fmt" + + "github.com/canonical/ofga" + jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/names/v5" + "github.com/juju/zaputil" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + jimmnames "github.com/canonical/jimm/v3/pkg/names" +) + +// ToOfferAccessString maps relation to an application offer access string. +func ToOfferAccessString(relation openfga.Relation) string { + switch relation { + case ofganames.AdministratorRelation: + return string(jujuparams.OfferAdminAccess) + case ofganames.ConsumerRelation: + return string(jujuparams.OfferConsumeAccess) + case ofganames.ReaderRelation: + return string(jujuparams.OfferReadAccess) + default: + return "" + } +} + +// ToCloudAccessString maps relation to a cloud access string. +func ToCloudAccessString(relation openfga.Relation) string { + switch relation { + case ofganames.AdministratorRelation: + return "admin" + case ofganames.CanAddModelRelation: + return "add-model" + default: + return "" + } +} + +// ToModelAccessString maps relation to a model access string. +func ToModelAccessString(relation openfga.Relation) string { + switch relation { + case ofganames.AdministratorRelation: + return "admin" + case ofganames.WriterRelation: + return "write" + case ofganames.ReaderRelation: + return "read" + default: + return "" + } +} + +// ToModelAccessString maps relation to a controller access string. +func ToControllerAccessString(relation openfga.Relation) string { + switch relation { + case ofganames.AdministratorRelation: + return "superuser" + default: + return "login" + } +} + +// ToCloudRelation returns a valid relation for the cloud. Access level +// string can be either "admin", in which case the administrator relation +// is returned, or "add-model", in which case the can_addmodel relation is +// returned. +func ToCloudRelation(accessLevel string) (openfga.Relation, error) { + switch accessLevel { + case "admin": + return ofganames.AdministratorRelation, nil + case "add-model": + return ofganames.CanAddModelRelation, nil + default: + return ofganames.NoRelation, errors.E("unknown cloud access") + } +} + +// ToModelRelation returns a valid relation for the model. +func ToModelRelation(accessLevel string) (openfga.Relation, error) { + switch accessLevel { + case "admin": + return ofganames.AdministratorRelation, nil + case "write": + return ofganames.WriterRelation, nil + case "read": + return ofganames.ReaderRelation, nil + default: + return ofganames.NoRelation, errors.E("unknown model access") + } +} + +// ToOfferRelation returns a valid relation for the application offer. +func ToOfferRelation(accessLevel string) (openfga.Relation, error) { + switch accessLevel { + case "": + return ofganames.NoRelation, nil + case string(jujuparams.OfferAdminAccess): + return ofganames.AdministratorRelation, nil + case string(jujuparams.OfferConsumeAccess): + return ofganames.ConsumerRelation, nil + case string(jujuparams.OfferReadAccess): + return ofganames.ReaderRelation, nil + default: + return ofganames.NoRelation, errors.E("unknown application offer access") + } +} + +// GetUserControllerAccess returns the user's level of access to the desired controller. +func (j *permissionManager) GetUserControllerAccess(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) { + accessLevel := user.GetControllerAccess(ctx, controller) + return ToControllerAccessString(accessLevel), nil +} + +// GetUserCloudAccess returns users access level for the specified cloud. +func (j *permissionManager) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) { + accessLevel := user.GetCloudAccess(ctx, cloud) + return ToCloudAccessString(accessLevel), nil +} + +// GetUserModelAccess returns the access level a user has against a specific model. +func (j *permissionManager) GetUserModelAccess(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) { + accessLevel := user.GetModelAccess(ctx, model) + return ToModelAccessString(accessLevel), nil +} + +// GrantAuditLogAccess grants audit log access for the target user. +func (j *permissionManager) GrantAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { + const op = errors.Op("jimm.GrantAuditLogAccess") + + access := user.GetControllerAccess(ctx, j.jimmTag) + if access != ofganames.AdministratorRelation { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := &dbmodel.Identity{} + targetUser.SetTag(targetUserTag) + err := j.store.GetIdentity(ctx, targetUser) + if err != nil { + return errors.E(op, err) + } + + err = openfga.NewUser(targetUser, j.authSvc).SetControllerAccess(ctx, j.jimmTag, ofganames.AuditLogViewerRelation) + if err != nil { + return errors.E(op, err) + } + return nil +} + +// RevokeAuditLogAccess revokes audit log access for the target user. +func (j *permissionManager) RevokeAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { + const op = errors.Op("jimm.RevokeAuditLogAccess") + + access := user.GetControllerAccess(ctx, j.jimmTag) + if access != ofganames.AdministratorRelation { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := &dbmodel.Identity{} + targetUser.SetTag(targetUserTag) + err := j.store.GetIdentity(ctx, targetUser) + if err != nil { + return errors.E(op, err) + } + + err = openfga.NewUser(targetUser, j.authSvc).UnsetAuditLogViewerAccess(ctx, j.jimmTag) + if err != nil { + return errors.E(op, err) + } + return nil +} + +// GrantServiceAccountAccess creates an administrator relation between the tags provided +// and the service account. The provided tags must be users or groups (with the member relation) +// otherwise OpenFGA will report an error. +func (j *permissionManager) GrantServiceAccountAccess(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error { + op := errors.Op("jimm.GrantServiceAccountAccess") + tags := make([]*ofganames.Tag, 0, len(entities)) + // Validate tags + for _, val := range entities { + tag, err := j.parseAndValidateTag(ctx, val) + if err != nil { + return errors.E(op, err) + } + if tag.Kind != openfga.UserType && tag.Kind != openfga.GroupType { + return errors.E(op, "invalid entity - not user or group") + } + if tag.Kind == openfga.GroupType { + tag.Relation = ofganames.MemberRelation + } + tags = append(tags, tag) + } + tuples := make([]openfga.Tuple, 0, len(tags)) + svcAccEntity := ofganames.ConvertTag(svcAccTag) + for _, tag := range tags { + tuple := openfga.Tuple{ + Object: tag, + Relation: ofganames.AdministratorRelation, + Target: svcAccEntity, + } + tuples = append(tuples, tuple) + } + err := j.authSvc.AddRelation(ctx, tuples...) + if err != nil { + zapctx.Error(ctx, "failed to add tuple(s)", zap.NamedError("add-relation-error", err)) + return errors.E(op, errors.CodeOpenFGARequestFailed, err) + } + return nil +} + +// CheckPermission loops over the desired permissions in desiredPerms and adds these permissions +// to cachedPerms if they exist. If the user does not have any of the desired permissions then an +// error is returned. +// Note that cachedPerms map is modified and returned. +func (j *permissionManager) CheckPermission(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) { + const op = errors.Op("jimm.CheckPermission") + for key, val := range desiredPerms { + if _, ok := cachedPerms[key]; !ok { + stringVal, ok := val.(string) + if !ok { + return nil, errors.E(op, fmt.Sprintf("failed to get permission assertion: expected %T, got %T", stringVal, val)) + } + tag, err := names.ParseTag(key) + if err != nil { + return cachedPerms, errors.E(op, fmt.Sprintf("failed to parse tag %s", key)) + } + relation, err := ofganames.ConvertJujuRelation(stringVal) + if err != nil { + return cachedPerms, errors.E(op, fmt.Sprintf("failed to parse relation %s", stringVal), err) + } + check, err := openfga.CheckRelation(ctx, user, tag, relation) + if err != nil { + return cachedPerms, errors.E(op, err) + } + if !check { + return cachedPerms, errors.E(op, fmt.Sprintf("Missing permission for %s:%s", key, val)) + } + cachedPerms[key] = stringVal + } + } + return cachedPerms, nil +} + +// GetJimmControllerAccess returns the JIMM controller access level for the +// requested user. +func (j *permissionManager) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) { + const op = errors.Op("jimm.GetJIMMControllerAccess") + + // If the authenticated user is requesting the access level + // for him/her-self then we return that - either the user + // is a JIMM admin (aka "superuser"), or they have a "login" + // access level. + if user.Name == tag.Id() { + if user.JimmAdmin { + return "superuser", nil + } + return "login", nil + } + + // Only JIMM administrators are allowed to see the access + // level of somebody else. + if !user.JimmAdmin { + return "", errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + var targetUser dbmodel.Identity + targetUser.SetTag(tag) + targetUserTag := openfga.NewUser(&targetUser, j.authSvc) + + // Check if the user is jimm administrator. + isAdmin, err := openfga.IsAdministrator(ctx, targetUserTag, j.jimmTag) + if err != nil { + zapctx.Error(ctx, "failed to check access rights", zap.Error(err)) + return "", errors.E(op, err) + } + if isAdmin { + return "superuser", nil + } + + return "login", nil +} + +// GrantCloudAccess grants the given access level on the given cloud to the +// given user. If the cloud is not found then an error with the code +// CodeNotFound is returned. If the authenticated user does not have admin +// access to the cloud then an error with the code CodeUnauthorized is +// returned. +func (j *permissionManager) GrantCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { + const op = errors.Op("jimm.GrantCloudAccess") + + targetRelation, err := ToCloudRelation(access) + if err != nil { + zapctx.Debug( + ctx, + "failed to recognize given access", + zaputil.Error(err), + zap.String("access", string(access)), + ) + return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) + } + + isCloudAdministrator, err := openfga.IsAdministrator(ctx, user, ct) + if err != nil { + return errors.E(op, err) + } + if !isCloudAdministrator { + // If the user doesn't have admin access on the cloud return + // an unauthorized error. + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := &dbmodel.Identity{} + targetUser.SetTag(ut) + if err := j.store.GetIdentity(ctx, targetUser); err != nil { + return err + } + targetOfgaUser := openfga.NewUser(targetUser, j.authSvc) + + currentRelation := targetOfgaUser.GetCloudAccess(ctx, ct) + switch targetRelation { + case ofganames.CanAddModelRelation: + switch currentRelation { + case ofganames.NoRelation: + break + default: + return nil + } + case ofganames.AdministratorRelation: + switch currentRelation { + case ofganames.NoRelation, ofganames.CanAddModelRelation: + break + default: + return nil + } + } + + err = targetOfgaUser.SetCloudAccess(ctx, ct, targetRelation) + if err != nil { + zapctx.Error( + ctx, + "failed to grant cloud access", + zaputil.Error(err), + zap.String("targetUser", string(ut.Id())), + zap.String("cloud", string(ct.Id())), + zap.String("access", string(access)), + ) + return errors.E(op, fmt.Errorf("failed to set cloud access: %w", err)) + } + return nil +} + +// RevokeCloudAccess revokes the given access level on the given cloud from +// the given user. If the cloud is not found then an error with the code +// CodeNotFound is returned. If the authenticated user does not have admin +// access to the cloud then an error with the code CodeUnauthorized is +// returned. +func (j *permissionManager) RevokeCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { + const op = errors.Op("jimm.RevokeCloudAccess") + + targetRelation, err := ToCloudRelation(access) + if err != nil { + zapctx.Debug( + ctx, + "failed to recognize given access", + zaputil.Error(err), + zap.String("access", string(access)), + ) + return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) + } + + isCloudAdministrator, err := openfga.IsAdministrator(ctx, user, ct) + if err != nil { + return errors.E(op, err) + } + if !isCloudAdministrator { + // If the user doesn't have admin access on the cloud return + // an unauthorized error. + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := &dbmodel.Identity{} + targetUser.SetTag(ut) + if err := j.store.GetIdentity(ctx, targetUser); err != nil { + return err + } + targetOfgaUser := openfga.NewUser(targetUser, j.authSvc) + + currentRelation := targetOfgaUser.GetCloudAccess(ctx, ct) + + var relationsToRevoke []openfga.Relation + switch targetRelation { + case ofganames.CanAddModelRelation: + switch currentRelation { + case ofganames.NoRelation: + return nil + default: + // If we're revoking "add-model" access, in addition to the "add-model" relation, we should also revoke the + // "admin" relation. That's because having an "admin" relation indirectly grants the "add-model" permission + // to the user. + relationsToRevoke = []openfga.Relation{ + ofganames.CanAddModelRelation, + ofganames.AdministratorRelation, + } + } + case ofganames.AdministratorRelation: + switch currentRelation { + case ofganames.NoRelation, ofganames.CanAddModelRelation: + return nil + default: + relationsToRevoke = []openfga.Relation{ + ofganames.AdministratorRelation, + } + } + } + + err = targetOfgaUser.UnsetCloudAccess(ctx, ct, relationsToRevoke...) + if err != nil { + zapctx.Error( + ctx, + "failed to revoke cloud access", + zaputil.Error(err), + zap.String("targetUser", string(ut.Id())), + zap.String("cloud", string(ct.Id())), + zap.String("access", string(access)), + ) + return errors.E(op, fmt.Errorf("failed to unset cloud access: %w", err)) + } + + return nil +} + +// GrantModelAccess grants the given access level on the given model to +// the given user. If the model is not found then an error with the code +// CodeNotFound is returned. If the authenticated user does not have +// admin access to the model then an error with the code CodeUnauthorized +// is returned. +func (j *permissionManager) GrantModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { + const op = errors.Op("jimm.GrantModelAccess") + zapctx.Info(ctx, string(op)) + + targetRelation, err := ToModelRelation(string(access)) + if err != nil { + zapctx.Debug( + ctx, + "failed to recognize given access", + zaputil.Error(err), + zap.String("access", string(access)), + ) + return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) + } + + modelAdmin, err := user.HasModelRelation(ctx, mt, ofganames.AdministratorRelation) + if err != nil { + return errors.E(op, err) + } + if !modelAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := &dbmodel.Identity{} + targetUser.SetTag(ut) + if err := j.store.GetIdentity(ctx, targetUser); err != nil { + return err + } + targetOfgaUser := openfga.NewUser(targetUser, j.authSvc) + + currentRelation := targetOfgaUser.GetModelAccess(ctx, mt) + switch targetRelation { + case ofganames.ReaderRelation: + switch currentRelation { + case ofganames.NoRelation: + break + default: + return nil + } + case ofganames.WriterRelation: + switch currentRelation { + case ofganames.NoRelation, ofganames.ReaderRelation: + break + default: + return nil + } + case ofganames.AdministratorRelation: + switch currentRelation { + case ofganames.NoRelation, ofganames.ReaderRelation, ofganames.WriterRelation: + break + default: + return nil + } + } + + err = targetOfgaUser.SetModelAccess(ctx, mt, targetRelation) + if err != nil { + zapctx.Error( + ctx, + "failed to grant model access", + zaputil.Error(err), + zap.String("targetUser", string(ut.Id())), + zap.String("model", string(mt.Id())), + zap.String("access", string(access)), + ) + return errors.E(op, fmt.Errorf("failed to set model access: %w", err)) + } + return nil +} + +// RevokeModelAccess revokes the given access level on the given model from +// the given user. If the model is not found then an error with the code +// CodeNotFound is returned. If the authenticated user does not have admin +// access to the model, and is not attempting to revoke their own access, +// then an error with the code CodeUnauthorized is returned. +func (j *permissionManager) RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { + const op = errors.Op("jimm.RevokeModelAccess") + zapctx.Info(ctx, string(op)) + + targetRelation, err := ToModelRelation(string(access)) + if err != nil { + zapctx.Debug( + ctx, + "failed to recognize given access", + zaputil.Error(err), + zap.String("access", string(access)), + ) + return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("failed to recognize given access: %q", access), err) + } + + requiredAccess := ofganames.AdministratorRelation + if user.Tag() == ut { + // If the user is attempting to revoke their own access. + requiredAccess = ofganames.ReaderRelation + } + + modelAdmin, err := user.HasModelRelation(ctx, mt, requiredAccess) + if err != nil { + return errors.E(op, err) + } + if !modelAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := &dbmodel.Identity{} + targetUser.SetTag(ut) + if err := j.store.GetIdentity(ctx, targetUser); err != nil { + return err + } + targetOfgaUser := openfga.NewUser(targetUser, j.authSvc) + + currentRelation := targetOfgaUser.GetModelAccess(ctx, mt) + + var relationsToRevoke []openfga.Relation + switch targetRelation { + case ofganames.ReaderRelation: + switch currentRelation { + case ofganames.NoRelation: + return nil + default: + relationsToRevoke = []openfga.Relation{ + ofganames.ReaderRelation, + ofganames.WriterRelation, + ofganames.AdministratorRelation, + } + } + case ofganames.WriterRelation: + switch currentRelation { + case ofganames.NoRelation, ofganames.ReaderRelation: + return nil + default: + relationsToRevoke = []openfga.Relation{ + ofganames.WriterRelation, + ofganames.AdministratorRelation, + } + } + case ofganames.AdministratorRelation: + switch currentRelation { + case ofganames.NoRelation, ofganames.ReaderRelation, ofganames.WriterRelation: + return nil + default: + relationsToRevoke = []openfga.Relation{ + ofganames.AdministratorRelation, + } + } + } + + err = targetOfgaUser.UnsetModelAccess(ctx, mt, relationsToRevoke...) + if err != nil { + zapctx.Error( + ctx, + "failed to revoke model access", + zaputil.Error(err), + zap.String("targetUser", string(ut.Id())), + zap.String("model", string(mt.Id())), + zap.String("access", string(access)), + ) + return errors.E(op, fmt.Errorf("failed to unset model access: %w", err)) + } + return nil +} + +// GrantOfferAccess grants rights for an application offer. +func (j *permissionManager) GrantOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error { + const op = errors.Op("jimm.GrantOfferAccess") + + identity, err := dbmodel.NewIdentity(ut.Id()) + if err != nil { + return errors.E(op, err) + } + + offer := dbmodel.ApplicationOffer{ + URL: offerURL, + } + if err := j.store.GetApplicationOffer(ctx, &offer); err != nil { + // If the offer is not found, we leak information about the existence of offers that do exist. + return errors.E(op, err) + } + + isOfferAdmin, err := openfga.IsAdministrator(ctx, user, offer.ResourceTag()) + if err != nil { + return errors.E(op, err) + } + if !isOfferAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := openfga.NewUser(identity, j.authSvc) + currentRelation := targetUser.GetApplicationOfferAccess(ctx, offer.ResourceTag()) + currentAccessLevel := ToOfferAccessString(currentRelation) + targetAccessLevel := determineAccessLevelAfterGrant(currentAccessLevel, string(access)) + + // NOTE (alesstimec) not removing the current access level as it might be an + // indirect relation. + if targetAccessLevel != currentAccessLevel { + relation, err := ToOfferRelation(targetAccessLevel) + if err != nil { + return errors.E(op, err) + } + err = targetUser.SetApplicationOfferAccess(ctx, offer.ResourceTag(), relation) + if err != nil { + return errors.E(op, err) + } + } + + return nil +} + +func determineAccessLevelAfterGrant(currentAccessLevel, grantAccessLevel string) string { + switch currentAccessLevel { + case string(jujuparams.OfferAdminAccess): + return string(jujuparams.OfferAdminAccess) + case string(jujuparams.OfferConsumeAccess): + switch grantAccessLevel { + case string(jujuparams.OfferAdminAccess): + return string(jujuparams.OfferAdminAccess) + default: + return string(jujuparams.OfferConsumeAccess) + } + case string(jujuparams.OfferReadAccess): + switch grantAccessLevel { + case string(jujuparams.OfferAdminAccess): + return string(jujuparams.OfferAdminAccess) + case string(jujuparams.OfferConsumeAccess): + return string(jujuparams.OfferConsumeAccess) + default: + return string(jujuparams.OfferReadAccess) + } + default: + return grantAccessLevel + } +} + +// RevokeOfferAccess revokes rights for an application offer. +func (j *permissionManager) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) { + const op = errors.Op("jimm.RevokeOfferAccess") + + identity, err := dbmodel.NewIdentity(ut.Id()) + if err != nil { + return errors.E(op, err) + } + + offer := dbmodel.ApplicationOffer{ + URL: offerURL, + } + if err := j.store.GetApplicationOffer(ctx, &offer); err != nil { + // If the offer is not found, we leak information about the existence of offers that do exist. + return errors.E(op, err) + } + + isOfferAdmin, err := openfga.IsAdministrator(ctx, user, offer.ResourceTag()) + if err != nil { + return errors.E(op, err) + } + if !isOfferAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + targetUser := openfga.NewUser(identity, j.authSvc) + targetRelation, err := ToOfferRelation(string(access)) + if err != nil { + return errors.E(op, err) + } + err = targetUser.UnsetApplicationOfferAccess(ctx, offer.ResourceTag(), targetRelation) + if err != nil { + return errors.E(op, err, "failed to unset given access") + } + + // Checking if the target user still has the given access to the + // application offer (which is possible because of indirect relations), + // and if so, returning an informative error. + currentRelation := targetUser.GetApplicationOfferAccess(ctx, offer.ResourceTag()) + stillHasAccess := false + switch targetRelation { + case ofganames.AdministratorRelation: + if currentRelation == ofganames.AdministratorRelation { + stillHasAccess = true + } + case ofganames.ConsumerRelation: + switch currentRelation { + case ofganames.AdministratorRelation, ofganames.ConsumerRelation: + stillHasAccess = true + } + case ofganames.ReaderRelation: + switch currentRelation { + case ofganames.AdministratorRelation, ofganames.ConsumerRelation, ofganames.ReaderRelation: + stillHasAccess = true + } + } + + if stillHasAccess { + return errors.E(op, "unable to completely revoke given access due to other relations; try to remove them as well, or use 'jimmctl' for more control") + } + return nil +} + +// OpenFGACleanup queries OpenFGA for all existing tuples, tries to resolve each tuple and removes those +// that JIMM cannot resolved - orphaned tuples. JIMM not being able to resolve a tuple means that the +// corresponding entity has been removed from JIMM's database. +// +// This approach to cleaning up tuples is intended to be temporary while we implement +// a better approach to eventual consistency of JIMM's database objects and OpenFGA tuples. +func (j *permissionManager) OpenFGACleanup(ctx context.Context) error { + var ( + continuationToken string + err error + tuples []ofga.Tuple + ) + for { + tuples, continuationToken, err = j.authSvc.ReadRelatedObjects(ctx, openfga.Tuple{}, 20, continuationToken) + if err != nil { + zapctx.Error(ctx, "reading all tuples", zap.Error(err)) + return err + } + + orphanedTuples := j.orphanedTuples(ctx, tuples...) + if len(orphanedTuples) > 0 { + zapctx.Debug(ctx, "removing orphaned tuples", zap.Any("tuples", orphanedTuples)) + err = j.authSvc.RemoveRelation(ctx, orphanedTuples...) + if err != nil { + zapctx.Warn(ctx, "failed to clean up orphaned tuples", zap.Error(err)) + } + } + if continuationToken == "" { + return nil + } + select { + case <-ctx.Done(): + return nil + default: + } + } +} + +func (j *permissionManager) orphanedTuples(ctx context.Context, tuples ...openfga.Tuple) []openfga.Tuple { + orphanedTuples := []openfga.Tuple{} + for _, tuple := range tuples { + _, err := j.ToJAASTag(ctx, tuple.Object, true) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + orphanedTuples = append(orphanedTuples, tuple) + continue + } + } + _, err = j.ToJAASTag(ctx, tuple.Target, true) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + orphanedTuples = append(orphanedTuples, tuple) + continue + } + } + } + return orphanedTuples +} diff --git a/internal/jimm/permissions/access_test.go b/internal/jimm/permissions/access_test.go new file mode 100644 index 000000000..b71540350 --- /dev/null +++ b/internal/jimm/permissions/access_test.go @@ -0,0 +1,2383 @@ +// Copyright 2025 Canonical. + +package permissions_test + +import ( + "context" + "fmt" + "testing" + + "github.com/canonical/ofga" + petname "github.com/dustinkirkland/golang-petname" + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm" + "github.com/canonical/jimm/v3/internal/jimm/permissions" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/testutils/jimmtest" + jimmnames "github.com/canonical/jimm/v3/pkg/names" +) + +func (s *permissionManagerSuite) TestAuditLogAccess(c *qt.C) { + c.Parallel() + + ctx := context.Background() + + // admin user can grant other users audit log access. + err := s.manager.GrantAuditLogAccess(ctx, s.adminUser, s.user.ResourceTag()) + c.Assert(err, qt.IsNil) + + access := s.user.GetAuditLogViewerAccess(ctx, s.ctlTag) + c.Assert(access, qt.Equals, ofganames.AuditLogViewerRelation) + + // re-granting access does not result in error. + err = s.manager.GrantAuditLogAccess(ctx, s.adminUser, s.user.ResourceTag()) + c.Assert(err, qt.IsNil) + + // admin user can revoke other users audit log access. + err = s.manager.RevokeAuditLogAccess(ctx, s.adminUser, s.user.ResourceTag()) + c.Assert(err, qt.IsNil) + + access = s.user.GetAuditLogViewerAccess(ctx, s.ctlTag) + c.Assert(access, qt.Equals, ofganames.NoRelation) + + // re-revoking access does not result in error. + err = s.manager.RevokeAuditLogAccess(ctx, s.adminUser, s.user.ResourceTag()) + c.Assert(err, qt.IsNil) + + // non-admin user cannot grant audit log access + err = s.manager.GrantAuditLogAccess(ctx, s.user, s.adminUser.ResourceTag()) + c.Assert(err, qt.ErrorMatches, "unauthorized") + + // non-admin user cannot revoke audit log access + err = s.manager.RevokeAuditLogAccess(ctx, s.user, s.adminUser.ResourceTag()) + c.Assert(err, qt.ErrorMatches, "unauthorized") +} + +func (s *permissionManagerSuite) TestGrantServiceAccountAccess(c *qt.C) { + c.Parallel() + + tests := []struct { + about string + grantServiceAccountAccess func(ctx context.Context, user *openfga.User, tags []string) error + clientID string + tags []string + username string + addGroups []string + expectedError string + }{{ + about: "Valid request", + grantServiceAccountAccess: func(ctx context.Context, user *openfga.User, tags []string) error { + return nil + }, + addGroups: []string{"1"}, + tags: []string{ + "user-alice", + "user-bob", + "group-1#member", + }, + clientID: "fca1f605-736e-4d1f-bcd2-aecc726923be@serviceaccount", + username: "alice", + }, { + about: "Group that doesn't exist", + grantServiceAccountAccess: func(ctx context.Context, user *openfga.User, tags []string) error { + return nil + }, + tags: []string{ + "user-alice", + "user-bob", + // This group doesn't exist. + "group-bar", + }, + clientID: "fca1f605-736e-4d1f-bcd2-aecc726923be@serviceaccount", + username: "alice", + expectedError: "group bar not found", + }, { + about: "Invalid tags", + grantServiceAccountAccess: func(ctx context.Context, user *openfga.User, tags []string) error { + return nil + }, + tags: []string{ + "user-alice", + "user-bob", + "controller-jimm", + }, + clientID: "fca1f605-736e-4d1f-bcd2-aecc726923be@serviceaccount", + username: "alice", + expectedError: "invalid entity - not user or group", + }} + + for _, test := range tests { + c.Run(test.about, func(c *qt.C) { + if len(test.addGroups) > 0 { + for _, name := range test.addGroups { + _, err := s.db.AddGroup(context.Background(), name) + c.Assert(err, qt.IsNil) + } + } + svcAccountTag := jimmnames.NewServiceAccountTag(test.clientID) + + err := s.manager.GrantServiceAccountAccess(context.Background(), s.adminUser, svcAccountTag, test.tags) + if test.expectedError == "" { + c.Assert(err, qt.IsNil) + for _, tag := range test.tags { + parsedTag, err := s.manager.ParseAndValidateTag(context.Background(), tag) + c.Assert(err, qt.IsNil) + tuple := openfga.Tuple{ + Object: parsedTag, + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(test.clientID)), + } + ok, err := s.ofgaClient.CheckRelation(context.Background(), tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(ok, qt.IsTrue) + } + } else { + c.Assert(err, qt.ErrorMatches, test.expectedError) + } + }) + } +} + +const testGetControllerAccessEnv = ` +users: +- username: alice@canonical.com + display-name: Alice + controller-access: superuser +- username: bob@canonical.com + display-name: Bob + controller-access: login +` + +func (s *permissionManagerSuite) TestGetControllerAccess(c *qt.C) { + ctx := context.Background() + + env := jimmtest.ParseEnvironment(c, testGetControllerAccessEnv) + env.PopulateDBAndPermissions(c, s.ctlTag, s.db, s.ofgaClient) + + access, err := s.manager.GetJimmControllerAccess(ctx, s.adminUser, s.adminUser.ResourceTag()) + c.Assert(err, qt.IsNil) + c.Check(access, qt.Equals, "superuser") + + access, err = s.manager.GetJimmControllerAccess(ctx, s.adminUser, s.user.ResourceTag()) + c.Assert(err, qt.IsNil) + c.Check(access, qt.Equals, "login") + + access, err = s.manager.GetJimmControllerAccess(ctx, s.adminUser, names.NewUserTag("charlie@canonical.com")) + c.Assert(err, qt.IsNil) + c.Check(access, qt.Equals, "login") + + access, err = s.manager.GetJimmControllerAccess(ctx, s.user, s.user.ResourceTag()) + c.Assert(err, qt.IsNil) + c.Check(access, qt.Equals, "login") + + _, err = s.manager.GetJimmControllerAccess(ctx, s.user, names.NewUserTag("alice@canonical.com")) + c.Assert(err, qt.ErrorMatches, "unauthorized") +} + +const grantModelAccessTestEnv = `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 + users: + - user: alice@canonical.com + access: admin + - user: charlie@canonical.com + access: write +` + +var grantModelAccessTests = []struct { + name string + env string + username string + uuid string + targetUsername string + access string + expectRelations []openfga.Tuple + expectError string + expectErrorCode errors.Code +}{{ + name: "ModelNotFound", + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "write", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "Admin grants 'admin' access to a user with no access", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin grants 'write' access to a user with no access", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin grants 'read' access to a user with no access", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin grants 'write' access to a user who already has 'write' access", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin grants 'read' access to a user who already has 'write' access", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin grants 'admin' access to themselves", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "alice@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin grants 'write' access to themselves", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "alice@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin grants 'read' access to themselves", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "alice@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "UserNotAuthorized", + env: grantModelAccessTestEnv, + username: "charlie@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "write", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "unknown access", + env: grantModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "some-unknown-access", + expectError: `failed to recognize given access: "some-unknown-access"`, +}} + +func TestGrantModelAccess(t *testing.T) { + c := qt.New(t) + for _, tt := range grantModelAccessTests { + c.Run(tt.name, func(c *qt.C) { + ctx := context.Background() + + j := jimmtest.NewJIMM(c, &jimm.Parameters{}) + + env := jimmtest.ParseEnvironment(c, tt.env) + env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) + + dbUser := env.User(tt.username).DBObject(c, j.Database) + user := openfga.NewUser(&dbUser, j.OpenFGAClient) + + err := j.PermissionManager().GrantModelAccess(ctx, user, names.NewModelTag(tt.uuid), names.NewUserTag(tt.targetUsername), jujuparams.UserAccessPermission(tt.access)) + if tt.expectError != "" { + c.Check(err, qt.ErrorMatches, tt.expectError) + if tt.expectErrorCode != "" { + c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) + } + return + } + c.Assert(err, qt.IsNil) + for _, tuple := range tt.expectRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after granting")) + } + }) + } +} + +const revokeModelAccessTestEnv = `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 + users: + - user: alice@canonical.com + access: admin + - user: bob@canonical.com + access: admin + - user: charlie@canonical.com + access: write + - user: daphne@canonical.com + access: read +- 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 + users: + - user: alice@canonical.com + access: admin + - user: earl@canonical.com + access: admin +` + +var revokeModelAccessTests = []struct { + name string + env string + username string + uuid string + targetUsername string + access string + extraInitialTuples []openfga.Tuple + expectRelations []openfga.Tuple + expectRemovedRelations []openfga.Tuple + expectError string + expectErrorCode errors.Code +}{{ + name: "ModelNotFound", + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "write", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "Admin revokes 'admin' access from another admin", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'write' access from another admin", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'read' access from another admin", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'admin' access from a user who has 'write' access", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'write' access from a user who has 'write' access", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'read' access from a user who has 'write' access", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'admin' access from a user who has 'read' access", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'write' access from a user who has 'read' access", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'read' access from a user who has 'read' access", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'admin' access from themselves", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "alice@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'write' access from themselves", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "alice@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'read' access from themselves", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "alice@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Writer revokes 'admin' access from themselves", + env: revokeModelAccessTestEnv, + username: "charlie@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Writer revokes 'write' access from themselves", + env: revokeModelAccessTestEnv, + username: "charlie@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Writer revokes 'read' access from themselves", + env: revokeModelAccessTestEnv, + username: "charlie@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "charlie@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Reader revokes 'admin' access from themselves", + env: revokeModelAccessTestEnv, + username: "daphne@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Reader revokes 'write' access from themselves", + env: revokeModelAccessTestEnv, + username: "daphne@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "write", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Reader revokes 'read' access from themselves", + env: revokeModelAccessTestEnv, + username: "daphne@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "read", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'admin' access from a user who has separate tuples for all accesses (read/write/admin)", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "admin", + extraInitialTuples: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, + // No need to add the 'read' relation, because it's already there due to the environment setup. + }, + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'write' access from a user who has separate tuples for all accesses (read/write/admin)", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "write", + extraInitialTuples: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, + // No need to add the 'read' relation, because it's already there due to the environment setup. + }, + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "Admin revokes 'read' access from a user who has separate tuples for all accesses (read/write/admin)", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "daphne@canonical.com", + access: "read", + extraInitialTuples: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, + // No need to add the 'read' relation, because it's already there due to the environment setup. + }, + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.WriterRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("daphne@canonical.com")), + Relation: ofganames.ReaderRelation, + Target: ofganames.ConvertTag(names.NewModelTag("00000002-0000-0000-0000-000000000001")), + }}, +}, { + name: "UserNotAuthorized", + env: revokeModelAccessTestEnv, + username: "charlie@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "write", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "unknown access", + env: revokeModelAccessTestEnv, + username: "alice@canonical.com", + uuid: "00000002-0000-0000-0000-000000000001", + targetUsername: "bob@canonical.com", + access: "some-unknown-access", + expectError: `failed to recognize given access: "some-unknown-access"`, +}} + +//nolint:gocognit +func TestRevokeModelAccess(t *testing.T) { + c := qt.New(t) + + for _, tt := range revokeModelAccessTests { + c.Run(tt.name, func(c *qt.C) { + ctx := context.Background() + + j := jimmtest.NewJIMM(c, &jimm.Parameters{}) + + env := jimmtest.ParseEnvironment(c, tt.env) + env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) + + if len(tt.extraInitialTuples) > 0 { + err := j.OpenFGAClient.AddRelation(ctx, tt.extraInitialTuples...) + c.Assert(err, qt.IsNil) + } + + if tt.expectRemovedRelations != nil { + for _, tuple := range tt.expectRemovedRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist before revoking")) + } + } + + dbUser := env.User(tt.username).DBObject(c, j.Database) + user := openfga.NewUser(&dbUser, j.OpenFGAClient) + + err := j.PermissionManager().RevokeModelAccess(ctx, user, names.NewModelTag(tt.uuid), names.NewUserTag(tt.targetUsername), jujuparams.UserAccessPermission(tt.access)) + if tt.expectError != "" { + c.Check(err, qt.ErrorMatches, tt.expectError) + if tt.expectErrorCode != "" { + c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) + } + return + } + c.Assert(err, qt.IsNil) + if tt.expectRemovedRelations != nil { + for _, tuple := range tt.expectRemovedRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsFalse, qt.Commentf("expected the tuple to be removed after revoking")) + } + } + if tt.expectRelations != nil { + for _, tuple := range tt.expectRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after revoking")) + } + } + }) + } +} + +func TestDetermineAccessLevelAfterGrant(t *testing.T) { + c := qt.New(t) + + tests := []struct { + about string + currentAccessLevel string + grantAccessLevel string + expectedAccessLevel string + }{{ + about: "user has no access - grant admin", + currentAccessLevel: "", + grantAccessLevel: string(jujuparams.OfferAdminAccess), + expectedAccessLevel: "admin", + }, { + about: "user has no access - grant consume", + currentAccessLevel: "", + grantAccessLevel: string(jujuparams.OfferConsumeAccess), + expectedAccessLevel: "consume", + }, { + about: "user has no access - grant read", + currentAccessLevel: "", + grantAccessLevel: string(jujuparams.OfferReadAccess), + expectedAccessLevel: "read", + }, { + about: "user has read access - grant admin", + currentAccessLevel: "read", + grantAccessLevel: string(jujuparams.OfferAdminAccess), + expectedAccessLevel: "admin", + }, { + about: "user has read access - grant consume", + currentAccessLevel: "read", + grantAccessLevel: string(jujuparams.OfferConsumeAccess), + expectedAccessLevel: "consume", + }, { + about: "user has read access - grant read", + currentAccessLevel: "read", + grantAccessLevel: string(jujuparams.OfferReadAccess), + expectedAccessLevel: "read", + }, { + about: "user has consume access - grant admin", + currentAccessLevel: "consume", + grantAccessLevel: string(jujuparams.OfferAdminAccess), + expectedAccessLevel: "admin", + }, { + about: "user has consume access - grant consume", + currentAccessLevel: "consume", + grantAccessLevel: string(jujuparams.OfferConsumeAccess), + expectedAccessLevel: "consume", + }, { + about: "user has consume access - grant read", + currentAccessLevel: "consume", + grantAccessLevel: string(jujuparams.OfferReadAccess), + expectedAccessLevel: "consume", + }, { + about: "user has admin access - grant admin", + currentAccessLevel: "admin", + grantAccessLevel: string(jujuparams.OfferAdminAccess), + expectedAccessLevel: "admin", + }, { + about: "user has admin access - grant consume", + currentAccessLevel: "admin", + grantAccessLevel: string(jujuparams.OfferConsumeAccess), + expectedAccessLevel: "admin", + }, { + about: "user has admin access - grant read", + currentAccessLevel: "admin", + grantAccessLevel: string(jujuparams.OfferReadAccess), + expectedAccessLevel: "admin", + }} + + for _, test := range tests { + c.Run(test.about, func(c *qt.C) { + level := permissions.DetermineAccessLevelAfterGrant(test.currentAccessLevel, test.grantAccessLevel) + c.Assert(level, qt.Equals, test.expectedAccessLevel) + }) + } +} + +const revokeAndGrantOfferAccessTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-region-1 +cloud-credentials: +- owner: alice@canonical.com + name: test-credential-1 + cloud: test-cloud +controllers: +- name: test-controller-1 + uuid: 00000000-0000-0000-0000-0000-0000000000001 + cloud: test-cloud + region: test-region-1 +models: +- name: test-model + uuid: 00000000-0000-0000-0000-0000-0000000000003 + controller: test-controller-1 + cloud: test-cloud + region: test-region-1 + cloud-credential: test-credential-1 + owner: alice@canonical.com + life: alive +application-offers: +- name: test-offer + url: test-offer-url + uuid: 00000000-0000-0000-0000-0000-0000000000011 + model-name: test-model + model-owner: alice@canonical.com + application-name: application-1 + application-description: app description 1 + users: + - user: eve@canonical.com + access: admin + - user: jane@canonical.com + access: admin + - user: bob@canonical.com + access: consume + - user: fred@canonical.com + access: read +users: +- username: grant@canonical.com + controller-access: login +- username: joe@canonical.com + controller-access: superuser +` + +func TestRevokeOfferAccess(t *testing.T) { + c := qt.New(t) + + ctx := context.Background() + + tests := []struct { + about string + parameterFunc func(*jimmtest.Environment, *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) + setup func(*jimmtest.Environment, *db.Database, *openfga.OFGAClient) + expectedError string + expectedAccessLevel string + expectedAccessLevelOnError string // This expectation is meant to ensure there'll be no unpredicted behavior (like changing existing relations) after an error has occurred + }{{ + about: "admin revokes a model admin user's admin access - an error returns (relation is indirect)", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("eve@canonical.com").DBObject(c, db), env.User("alice@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedError: "unable to completely revoke given access due to other relations.*", + expectedAccessLevelOnError: "admin", + }, { + about: "model admin revokes an admin user admin access - user has no access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("eve@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + + }, + expectedAccessLevel: "", + }, { + about: "admin revokes an admin user admin access - user has no access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("jane@canonical.com").DBObject(c, db), env.User("eve@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "", + }, { + about: "superuser revokes an admin user admin access - user has no access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("joe@canonical.com").DBObject(c, db), env.User("eve@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "", + }, { + about: "admin revokes an admin user read access - an error returns (no direct relation to remove)", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("eve@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedError: "unable to completely revoke given access due to other relations.*", + expectedAccessLevelOnError: "admin", + }, { + about: "admin revokes a consume user admin access - user keeps consume access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "consume", + }, { + about: "admin revokes a consume user consume access - user has no access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedAccessLevel: "", + }, { + about: "admin revokes a consume user read access - user still has consume access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedError: "unable to completely revoke given access due to other relations.*", + expectedAccessLevelOnError: "consume", + }, { + about: "admin revokes a read user admin access - user keeps read access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "read", + }, { + about: "admin revokes a read user consume access - user keeps read access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedAccessLevel: "read", + }, { + about: "admin revokes a read user read access - user has no access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedAccessLevel: "", + }, { + about: "admin tries to revoke access to user that does not have access - user continues to have no access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedAccessLevel: "", + }, { + about: "user with consume access cannot revoke access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("bob@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedError: "unauthorized", + }, { + about: "user with read access cannot revoke access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("fred@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedError: "unauthorized", + }, { + about: "no such offer", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("fred@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "no-such-offer", jujuparams.OfferReadAccess + }, + expectedError: "application offer not found", + }, { + about: "admin revokes another user (who is direct admin+consumer) their consume access - an error returns (saying user still has access; hinting to use 'jimmctl' for advanced cases)", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("eve@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + setup: func(env *jimmtest.Environment, db *db.Database, client *openfga.OFGAClient) { + u := env.User("grant@canonical.com").DBObject(c, db) + offer := env.ApplicationOffer("test-offer-url").DBObject(c, db) + err := openfga.NewUser(&u, client).SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.ConsumerRelation) + c.Assert(err, qt.IsNil) + err = openfga.NewUser(&u, client).SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.AdministratorRelation) + c.Assert(err, qt.IsNil) + }, + expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", + expectedAccessLevelOnError: "admin", + }, { + about: "admin revokes another user (who is direct admin+reader) their read access - an error returns (saying user still has access; hinting to use 'jimmctl' for advanced cases)", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("eve@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + setup: func(env *jimmtest.Environment, db *db.Database, client *openfga.OFGAClient) { + u := env.User("grant@canonical.com").DBObject(c, db) + offer := env.ApplicationOffer("test-offer-url").DBObject(c, db) + err := openfga.NewUser(&u, client).SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.ReaderRelation) + c.Assert(err, qt.IsNil) + err = openfga.NewUser(&u, client).SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.AdministratorRelation) + c.Assert(err, qt.IsNil) + }, + expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", + expectedAccessLevelOnError: "admin", + }, { + about: "admin revokes another user (who is direct consumer+reader) their read access - an error returns (saying user still has access; hinting to use 'jimmctl' for advanced cases)", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("eve@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + setup: func(env *jimmtest.Environment, db *db.Database, client *openfga.OFGAClient) { + u := env.User("grant@canonical.com").DBObject(c, db) + offer := env.ApplicationOffer("test-offer-url").DBObject(c, db) + err := openfga.NewUser(&u, client).SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.ReaderRelation) + c.Assert(err, qt.IsNil) + err = openfga.NewUser(&u, client).SetApplicationOfferAccess(ctx, offer.ResourceTag(), ofganames.ConsumerRelation) + c.Assert(err, qt.IsNil) + }, + expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", + expectedAccessLevelOnError: "consume", + }} + + for _, test := range tests { + c.Run(test.about, func(c *qt.C) { + j := jimmtest.NewJIMM(c, &jimm.Parameters{}) + + env := jimmtest.ParseEnvironment(c, revokeAndGrantOfferAccessTestEnv) + env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) + + if test.setup != nil { + test.setup(env, j.Database, j.OpenFGAClient) + } + authenticatedUser, offerUser, offerURL, revokeAccessLevel := test.parameterFunc(env, j.Database) + + assertAppliedRelation := func(expectedAppliedRelation string) { + offer := dbmodel.ApplicationOffer{ + URL: offerURL, + } + err := j.Database.GetApplicationOffer(ctx, &offer) + c.Assert(err, qt.IsNil) + appliedRelation := openfga.NewUser(&offerUser, j.OpenFGAClient).GetApplicationOfferAccess(ctx, offer.ResourceTag()) + c.Assert(permissions.ToOfferAccessString(appliedRelation), qt.Equals, expectedAppliedRelation) + } + + err := j.PermissionManager().RevokeOfferAccess(ctx, openfga.NewUser(&authenticatedUser, j.OpenFGAClient), offerURL, offerUser.ResourceTag(), revokeAccessLevel) + if test.expectedError == "" { + c.Assert(err, qt.IsNil) + assertAppliedRelation(test.expectedAccessLevel) + } else { + c.Assert(err, qt.ErrorMatches, test.expectedError) + if test.expectedAccessLevelOnError != "" { + assertAppliedRelation(test.expectedAccessLevelOnError) + } + } + }) + } +} + +func TestGrantOfferAccess(t *testing.T) { + c := qt.New(t) + + ctx := context.Background() + + tests := []struct { + about string + parameterFunc func(*jimmtest.Environment, *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) + expectedError string + expectedAccessLevel string + }{{ + about: "model admin grants an admin user admin access - admin user keeps admin", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("eve@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "admin", + }, { + about: "model admin grants an admin user consume access - admin user keeps admin", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("eve@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedAccessLevel: "admin", + }, { + about: "model admin grants an admin user read access - admin user keeps admin", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("eve@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedAccessLevel: "admin", + }, { + about: "model admin grants a consume user admin access - user gets admin access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "admin", + }, { + about: "admin grants a consume user admin access - user gets admin access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("jane@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "admin", + }, { + about: "superuser grants a consume user admin access - user gets admin access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("joe@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "admin", + }, { + about: "admin grants a consume user consume access - user keeps consume access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedAccessLevel: "consume", + }, { + about: "admin grants a consume user read access - use keeps consume access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("bob@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedAccessLevel: "consume", + }, { + about: "admin grants a read user admin access - user gets admin access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "admin", + }, { + about: "admin grants a read user consume access - user gets consume access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedAccessLevel: "consume", + }, { + about: "admin grants a read user read access - user keeps read access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedAccessLevel: "read", + }, { + about: "no such offer", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("fred@canonical.com").DBObject(c, db), "no-such-offer", jujuparams.OfferReadAccess + }, + expectedError: "application offer not found", + }, { + about: "user with consume rights cannot grant any rights", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("bob@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedError: "unauthorized", + }, { + about: "user with read rights cannot grant any rights", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("fred@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedError: "unauthorized", + }, { + about: "admin grants new user admin access - new user has admin access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferAdminAccess + }, + expectedAccessLevel: "admin", + }, { + about: "admin grants new user consume access - new user has consume access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferConsumeAccess + }, + expectedAccessLevel: "consume", + }, { + about: "admin grants new user read access - new user has read access", + parameterFunc: func(env *jimmtest.Environment, db *db.Database) (dbmodel.Identity, dbmodel.Identity, string, jujuparams.OfferAccessPermission) { + return env.User("alice@canonical.com").DBObject(c, db), env.User("grant@canonical.com").DBObject(c, db), "test-offer-url", jujuparams.OfferReadAccess + }, + expectedAccessLevel: "read", + }} + + for _, test := range tests { + c.Run(test.about, func(c *qt.C) { + j := jimmtest.NewJIMM(c, &jimm.Parameters{}) + + env := jimmtest.ParseEnvironment(c, revokeAndGrantOfferAccessTestEnv) + env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) + + authenticatedUser, offerUser, offerURL, grantAccessLevel := test.parameterFunc(env, j.Database) + + err := j.PermissionManager().GrantOfferAccess(ctx, openfga.NewUser(&authenticatedUser, j.OpenFGAClient), offerURL, offerUser.ResourceTag(), grantAccessLevel) + if test.expectedError == "" { + c.Assert(err, qt.IsNil) + + offer := dbmodel.ApplicationOffer{ + URL: offerURL, + } + err = j.Database.GetApplicationOffer(ctx, &offer) + c.Assert(err, qt.IsNil) + appliedRelation := openfga.NewUser(&offerUser, j.OpenFGAClient).GetApplicationOfferAccess(ctx, offer.ResourceTag()) + c.Assert(permissions.ToOfferAccessString(appliedRelation), qt.Equals, test.expectedAccessLevel) + } else { + c.Assert(err, qt.ErrorMatches, test.expectedError) + } + }) + } +} + +const grantCloudAccessTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-cloud-region +- name: test + type: kubernetes + host-cloud-region: test-cloud/test-cloud-region + regions: + - name: default + - name: region2 + users: + - user: alice@canonical.com + access: admin +controllers: +- name: controller-1 + uuid: 00000001-0000-0000-0000-000000000001 + cloud: test-cloud + region: test-cloud-region + cloud-regions: + - cloud: test-cloud + region: test-cloud-region + priority: 10 + - cloud: test + region: default + priority: 1 + - cloud: test + region: region2 + priority: 1 +` + +var grantCloudAccessTests = []struct { + name string + env string + username string + cloud string + targetUsername string + access string + expectRelations []openfga.Tuple + expectError string + expectErrorCode errors.Code +}{{ + name: "CloudNotFound", + username: "alice@canonical.com", + cloud: "test2", + targetUsername: "bob@canonical.com", + access: "add-model", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "Admin grants admin access", + env: grantCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "Admin grants add-model access", + env: grantCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "add-model", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "UserNotAuthorized", + env: grantCloudAccessTestEnv, + username: "charlie@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "add-model", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "unknown access", + env: grantCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "some-unknown-access", + expectError: `failed to recognize given access: "some-unknown-access"`, +}} + +func TestGrantCloudAccess(t *testing.T) { + c := qt.New(t) + + for _, t := range grantCloudAccessTests { + tt := t + c.Run(tt.name, func(c *qt.C) { + ctx := context.Background() + + env := jimmtest.ParseEnvironment(c, tt.env) + + j := jimmtest.NewJIMM(c, &jimm.Parameters{}) + + env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) + + dbUser := env.User(tt.username).DBObject(c, j.Database) + user := openfga.NewUser(&dbUser, j.OpenFGAClient) + + err := j.PermissionManager().GrantCloudAccess(ctx, user, names.NewCloudTag(tt.cloud), names.NewUserTag(tt.targetUsername), tt.access) + if tt.expectError != "" { + c.Check(err, qt.ErrorMatches, tt.expectError) + if tt.expectErrorCode != "" { + c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) + } + return + } + c.Assert(err, qt.IsNil) + for _, tuple := range tt.expectRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after granting")) + } + }) + } +} + +const revokeCloudAccessTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-cloud-region + users: + - user: daphne@canonical.com + access: admin +- name: test + type: kubernetes + host-cloud-region: test-cloud/test-cloud-region + regions: + - name: default + users: + - user: alice@canonical.com + access: admin + - user: bob@canonical.com + access: admin + - user: charlie@canonical.com + access: add-model +controllers: +- name: controller-1 + uuid: 00000001-0000-0000-0000-000000000001 + cloud: test-cloud + region: test-cloud-region + cloud-regions: + - cloud: test-cloud + region: test-cloud-region + priority: 10 + - cloud: test + region: default + priority: 1 +` + +var revokeCloudAccessTests = []struct { + name string + env string + username string + cloud string + targetUsername string + access string + extraInitialTuples []openfga.Tuple + expectRelations []openfga.Tuple + expectRemovedRelations []openfga.Tuple + expectError string + expectErrorCode errors.Code +}{{ + name: "CloudNotFound", + username: "alice@canonical.com", + cloud: "test2", + targetUsername: "bob@canonical.com", + access: "admin", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "Admin revokes 'admin' from another admin", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "Admin revokes 'add-model' from another admin", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "add-model", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "Admin revokes 'add-model' from a user with 'add-model' access", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "charlie@canonical.com", + access: "add-model", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "Admin revokes 'add-model' from a user with no access", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "daphne@canonical.com", + access: "add-model", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "Admin revokes 'admin' from a user with no access", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "daphne@canonical.com", + access: "admin", + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "Admin revokes 'add-model' access from a user who has separate tuples for all accesses (add-model/admin)", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "charlie@canonical.com", + access: "add-model", + extraInitialTuples: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, + // No need to add the 'add-model' relation, because it's already there due to the environment setup. + }, + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "Admin revokes 'admin' access from a user who has separate tuples for all accesses (add-model/admin)", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "charlie@canonical.com", + access: "admin", + extraInitialTuples: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, + // No need to add the 'add-model' relation, because it's already there due to the environment setup. + }, + expectRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("alice@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("bob@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }, { + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.CanAddModelRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, + expectRemovedRelations: []openfga.Tuple{{ + Object: ofganames.ConvertTag(names.NewUserTag("charlie@canonical.com")), + Relation: ofganames.AdministratorRelation, + Target: ofganames.ConvertTag(names.NewCloudTag("test")), + }}, +}, { + name: "UserNotAuthorized", + env: revokeCloudAccessTestEnv, + username: "charlie@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "add-model", + expectError: `unauthorized`, + expectErrorCode: errors.CodeUnauthorized, +}, { + name: "unknown access", + env: revokeCloudAccessTestEnv, + username: "alice@canonical.com", + cloud: "test", + targetUsername: "bob@canonical.com", + access: "some-unknown-access", + expectError: `failed to recognize given access: "some-unknown-access"`, +}} + +//nolint:gocognit +func TestRevokeCloudAccess(t *testing.T) { + c := qt.New(t) + + for _, t := range revokeCloudAccessTests { + tt := t + c.Run(tt.name, func(c *qt.C) { + ctx := context.Background() + + env := jimmtest.ParseEnvironment(c, tt.env) + + j := jimmtest.NewJIMM(c, &jimm.Parameters{}) + + env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, j.OpenFGAClient) + + if len(tt.extraInitialTuples) > 0 { + err := j.OpenFGAClient.AddRelation(ctx, tt.extraInitialTuples...) + c.Assert(err, qt.IsNil) + } + + if tt.expectRemovedRelations != nil { + for _, tuple := range tt.expectRemovedRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist before revoking")) + } + } + + dbUser := env.User(tt.username).DBObject(c, j.Database) + user := openfga.NewUser(&dbUser, j.OpenFGAClient) + + err := j.PermissionManager().RevokeCloudAccess(ctx, user, names.NewCloudTag(tt.cloud), names.NewUserTag(tt.targetUsername), tt.access) + if tt.expectError != "" { + c.Check(err, qt.ErrorMatches, tt.expectError) + if tt.expectErrorCode != "" { + c.Check(errors.ErrorCode(err), qt.Equals, tt.expectErrorCode) + } + return + } + c.Assert(err, qt.IsNil) + if tt.expectRemovedRelations != nil { + for _, tuple := range tt.expectRemovedRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsFalse, qt.Commentf("expected the tuple to be removed after revoking")) + } + } + if tt.expectRelations != nil { + for _, tuple := range tt.expectRelations { + value, err := j.OpenFGAClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(value, qt.IsTrue, qt.Commentf("expected the tuple to exist after revoking")) + } + } + }) + } +} + +func (s *permissionManagerSuite) TestParseAndValidateTag(c *qt.C) { + c.Parallel() + ctx := context.Background() + + user, _, _, model, _, _, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, s.db) + + jimmTag := "model-" + user.Name + "/" + model.Name + "#administrator" + + // JIMM tag syntax for models + tag, err := s.manager.ParseAndValidateTag(ctx, jimmTag) + c.Assert(err, qt.IsNil) + c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) + c.Assert(tag.ID, qt.Equals, model.UUID.String) + c.Assert(tag.Relation.String(), qt.Equals, "administrator") + + jujuTag := "model-" + model.UUID.String + "#administrator" + + // Juju tag syntax for models + tag, err = s.manager.ParseAndValidateTag(ctx, jujuTag) + c.Assert(err, qt.IsNil) + c.Assert(tag.ID, qt.Equals, model.UUID.String) + c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) + c.Assert(tag.Relation.String(), qt.Equals, "administrator") + + // JIMM tag only kind + kindTag := "model" + tag, err = s.manager.ParseAndValidateTag(ctx, kindTag) + c.Assert(err, qt.IsNil) + c.Assert(tag.ID, qt.Equals, "") + c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) + + // JIMM tag not valid + _, err = s.manager.ParseAndValidateTag(ctx, "") + c.Assert(err, qt.ErrorMatches, "unknown tag kind") +} + +func (s *permissionManagerSuite) TestResolveTags(c *qt.C) { + c.Parallel() + ctx := context.Background() + + identity, group, controller, model, offer, cloud, _, role := jimmtest.CreateTestControllerEnvironment(ctx, c, s.db) + + testCases := []struct { + desc string + input string + expected *ofga.Entity + }{{ + desc: "map identity name with relation", + input: "user-" + identity.Name + "#member", + expected: ofganames.ConvertTagWithRelation(names.NewUserTag(identity.Name), ofganames.MemberRelation), + }, { + desc: "map group name with relation", + input: "group-" + group.Name + "#member", + expected: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), + }, { + desc: "map group UUID", + input: "group-" + group.UUID, + expected: ofganames.ConvertTag(jimmnames.NewGroupTag(group.UUID)), + }, { + desc: "map group UUID with relation", + input: "group-" + group.UUID + "#member", + expected: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), + }, { + desc: "map role UUID", + input: "role-" + role.UUID, + expected: ofganames.ConvertTag(jimmnames.NewRoleTag(role.UUID)), + }, { + desc: "map role UUID with relation", + input: "role-" + role.UUID + "#assignee", + expected: ofganames.ConvertTagWithRelation(jimmnames.NewRoleTag(role.UUID), ofganames.AssigneeRelation), + }, { + desc: "map jimm controller", + input: "controller-" + "jimm", + expected: ofganames.ConvertTag(s.ctlTag), + }, { + desc: "map controller", + input: "controller-" + controller.Name + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewControllerTag(model.UUID.String), ofganames.AdministratorRelation), + }, { + desc: "map controller UUID", + input: "controller-" + controller.UUID, + expected: ofganames.ConvertTag(names.NewControllerTag(model.UUID.String)), + }, { + desc: "map model", + input: "model-" + model.OwnerIdentityName + "/" + model.Name + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), ofganames.AdministratorRelation), + }, { + desc: "map model UUID", + input: "model-" + model.UUID.String, + expected: ofganames.ConvertTag(names.NewModelTag(model.UUID.String)), + }, { + desc: "map offer", + input: "applicationoffer-" + offer.URL + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), ofganames.AdministratorRelation), + }, { + desc: "map offer UUID", + input: "applicationoffer-" + offer.UUID, + expected: ofganames.ConvertTag(names.NewApplicationOfferTag(offer.UUID)), + }, { + desc: "map cloud", + input: "cloud-" + cloud.Name + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewCloudTag(cloud.Name), ofganames.AdministratorRelation), + }} + + for _, tC := range testCases { + c.Run(tC.desc, func(c *qt.C) { + jujuTag, err := permissions.ResolveTag(s.ctlTag.Id(), s.db, tC.input) + c.Assert(err, qt.IsNil) + c.Assert(jujuTag, qt.DeepEquals, tC.expected) + }) + } +} + +func (s *permissionManagerSuite) TestResolveTupleObjectHandlesErrors(c *qt.C) { + c.Parallel() + ctx := context.Background() + + _, _, controller, model, offer, _, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, s.db) + + type test struct { + input string + want string + } + + tests := []test{ + // Resolves bad tuple objects in general + { + input: "unknowntag-blabla", + want: "failed to map tag, unknown kind: unknowntag", + }, + // Resolves bad groups where they do not exist + { + input: "group-myspecialpokemon-his-name-is-youguessedit-diglett", + want: "group myspecialpokemon-his-name-is-youguessedit-diglett not found", + }, + // Resolves bad controllers where they do not exist + { + input: "controller-mycontroller-that-does-not-exist", + want: "controller not found", + }, + // Resolves bad models where the user cannot be obtained from the JIMM tag + { + input: "model-mycontroller-that-does-not-exist/mymodel", + want: "model not found", + }, + // Resolves bad models where it cannot be found on the specified controller + { + input: "model-" + controller.Name + ":alex/", + want: "model name format incorrect, expected /", + }, + // Resolves bad applicationoffers where it cannot be found on the specified controller/model combo + { + input: "applicationoffer-" + controller.Name + ":alex/" + model.Name + "." + offer.UUID + "fluff", + want: "application offer not found", + }, + { + input: "abc", + want: "failed to setup tag resolver: tag is not properly formatted", + }, + { + input: "model-test-unknowncontroller-1:alice@canonical.com/test-model-1", + want: "model not found", + }, + } + for i, tc := range tests { + c.Run(fmt.Sprintf("test %d", i), func(c *qt.C) { + _, err := permissions.ResolveTag(s.ctlTag.Id(), s.db, tc.input) + c.Assert(err, qt.ErrorMatches, tc.want) + }) + } +} + +func (s *permissionManagerSuite) TestToJAASTag(c *qt.C) { + c.Parallel() + ctx := context.Background() + + user, group, controller, model, applicationOffer, cloud, _, role := jimmtest.CreateTestControllerEnvironment(ctx, c, s.db) + + serviceAccountId := petname.Generate(2, "-") + "@serviceaccount" + + tests := []struct { + tag *ofganames.Tag + expectedJAASTag string + expectedError string + }{{ + tag: ofganames.ConvertTag(user.ResourceTag()), + expectedJAASTag: "user-" + user.Name, + }, { + tag: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(serviceAccountId)), + expectedJAASTag: "serviceaccount-" + serviceAccountId, + }, { + tag: ofganames.ConvertTag(group.ResourceTag()), + expectedJAASTag: "group-" + group.Name, + }, { + tag: ofganames.ConvertTag(controller.ResourceTag()), + expectedJAASTag: "controller-" + controller.Name, + }, { + tag: ofganames.ConvertTag(model.ResourceTag()), + expectedJAASTag: "model-" + user.Name + "/" + model.Name, + }, { + tag: ofganames.ConvertTag(applicationOffer.ResourceTag()), + expectedJAASTag: "applicationoffer-" + applicationOffer.URL, + }, { + tag: &ofganames.Tag{}, + expectedError: "unexpected tag kind: ", + }, { + tag: ofganames.ConvertTag(cloud.ResourceTag()), + expectedJAASTag: "cloud-" + cloud.Name, + }, { + tag: ofganames.ConvertTag(role.ResourceTag()), + expectedJAASTag: "role-" + role.Name, + }} + for _, test := range tests { + t, err := s.manager.ToJAASTag(ctx, test.tag, true) + if test.expectedError != "" { + c.Assert(err, qt.ErrorMatches, test.expectedError) + } else { + c.Assert(err, qt.IsNil) + c.Assert(t, qt.Equals, test.expectedJAASTag) + } + } +} + +func (s *permissionManagerSuite) TestToJAASTagNoUUIDResolution(c *qt.C) { + c.Parallel() + ctx := context.Background() + + user, group, controller, model, applicationOffer, cloud, _, role := jimmtest.CreateTestControllerEnvironment(ctx, c, s.db) + serviceAccountId := petname.Generate(2, "-") + "@serviceaccount" + + tests := []struct { + tag *ofganames.Tag + expectedJAASTag string + expectedError string + }{{ + tag: ofganames.ConvertTag(user.ResourceTag()), + expectedJAASTag: "user-" + user.Name, + }, { + tag: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(serviceAccountId)), + expectedJAASTag: "serviceaccount-" + serviceAccountId, + }, { + tag: ofganames.ConvertTag(group.ResourceTag()), + expectedJAASTag: "group-" + group.UUID, + }, { + tag: ofganames.ConvertTag(controller.ResourceTag()), + expectedJAASTag: "controller-" + controller.UUID, + }, { + tag: ofganames.ConvertTag(model.ResourceTag()), + expectedJAASTag: "model-" + model.UUID.String, + }, { + tag: ofganames.ConvertTag(applicationOffer.ResourceTag()), + expectedJAASTag: "applicationoffer-" + applicationOffer.UUID, + }, { + tag: ofganames.ConvertTag(cloud.ResourceTag()), + expectedJAASTag: "cloud-" + cloud.Name, + }, { + tag: ofganames.ConvertTag(role.ResourceTag()), + expectedJAASTag: "role-" + role.UUID, + }, { + tag: &ofganames.Tag{}, + expectedJAASTag: "-", + }} + for _, test := range tests { + t, err := s.manager.ToJAASTag(ctx, test.tag, false) + if test.expectedError != "" { + c.Assert(err, qt.ErrorMatches, test.expectedError) + } else { + c.Assert(err, qt.IsNil) + c.Assert(t, qt.Equals, test.expectedJAASTag) + } + } +} + +func (s *permissionManagerSuite) TestOpenFGACleanup(c *qt.C) { + c.Parallel() + ctx := context.Background() + + // run cleanup on an empty authorizaton store + err := s.manager.OpenFGACleanup(ctx) + c.Assert(err, qt.IsNil) + + type createTagFunction func(int) *ofga.Entity + + var ( + createStringTag = func(kind openfga.Kind) createTagFunction { + return func(i int) *ofga.Entity { + return &ofga.Entity{ + Kind: kind, + ID: fmt.Sprintf("%s-%d", petname.Generate(2, "-"), i), + } + } + } + + createUUIDTag = func(kind openfga.Kind) createTagFunction { + return func(i int) *ofga.Entity { + return &ofga.Entity{ + Kind: kind, + ID: uuid.NewString(), + } + } + } + ) + + tagTests := []struct { + createObjectTag createTagFunction + relation string + createTargetTag createTagFunction + }{{ + createObjectTag: createStringTag(openfga.UserType), + relation: "member", + createTargetTag: createStringTag(openfga.GroupType), + }, { + createObjectTag: createStringTag(openfga.UserType), + relation: "administrator", + createTargetTag: createUUIDTag(openfga.ControllerType), + }, { + createObjectTag: createStringTag(openfga.UserType), + relation: "reader", + createTargetTag: createUUIDTag(openfga.ModelType), + }, { + createObjectTag: createStringTag(openfga.UserType), + relation: "administrator", + createTargetTag: createStringTag(openfga.CloudType), + }, { + createObjectTag: createStringTag(openfga.UserType), + relation: "consumer", + createTargetTag: createUUIDTag(openfga.ApplicationOfferType), + }} + + orphanedTuples := []ofga.Tuple{} + for i := 0; i < 100; i++ { + for _, test := range tagTests { + objectTag := test.createObjectTag(i) + targetTag := test.createTargetTag(i) + + tuple := openfga.Tuple{ + Object: objectTag, + Relation: ofga.Relation(test.relation), + Target: targetTag, + } + err = s.ofgaClient.AddRelation(ctx, tuple) + c.Assert(err, qt.IsNil) + + orphanedTuples = append(orphanedTuples, tuple) + } + } + + err = s.manager.OpenFGACleanup(ctx) + c.Assert(err, qt.IsNil) + + for _, tuple := range orphanedTuples { + c.Logf("checking relation for %+v", tuple) + ok, err := s.ofgaClient.CheckRelation(ctx, tuple, false) + c.Assert(err, qt.IsNil) + c.Assert(ok, qt.IsFalse) + } +} diff --git a/internal/jimm/permissions/export_test.go b/internal/jimm/permissions/export_test.go new file mode 100644 index 000000000..3efc59c5e --- /dev/null +++ b/internal/jimm/permissions/export_test.go @@ -0,0 +1,21 @@ +// Copyright 2025 Canonical. + +package permissions + +import ( + "context" + + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" +) + +var ( + ResolveTag = resolveTag + DetermineAccessLevelAfterGrant = determineAccessLevelAfterGrant +) + +// PermissionManager is a type alias to export PermissionManager for use in tests. +type PermissionManager = permissionManager + +func (j *permissionManager) ParseAndValidateTag(ctx context.Context, key string) (*ofganames.Tag, error) { + return j.parseAndValidateTag(ctx, key) +} diff --git a/internal/jimm/permissions/permissionmanager.go b/internal/jimm/permissions/permissionmanager.go new file mode 100644 index 000000000..6af3fb528 --- /dev/null +++ b/internal/jimm/permissions/permissionmanager.go @@ -0,0 +1,32 @@ +// Copyright 2025 Canonical. + +// The permissions package provides business logic for handling user permissions. +package permissions + +import ( + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// permissionManager provides a means to manage roles within JIMM. +type permissionManager struct { + store *db.Database + authSvc *openfga.OFGAClient + jimmUUID string + jimmTag names.ControllerTag +} + +// NewManager returns a new permission manager that provides +// permission handling and resolution of JAAS tags. +func NewManager(store *db.Database, authSvc *openfga.OFGAClient, uuid string, tag names.ControllerTag) (*permissionManager, error) { + if store == nil { + return nil, errors.E("permission store cannot be nil") + } + if authSvc == nil { + return nil, errors.E("permission authorisation service cannot be nil") + } + return &permissionManager{store, authSvc, uuid, tag}, nil +} diff --git a/internal/jimm/relation.go b/internal/jimm/permissions/relations.go similarity index 79% rename from internal/jimm/relation.go rename to internal/jimm/permissions/relations.go index b17c9ed74..000a558d3 100644 --- a/internal/jimm/relation.go +++ b/internal/jimm/permissions/relations.go @@ -1,6 +1,6 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. -package jimm +package permissions import ( "context" @@ -18,7 +18,7 @@ import ( // AddRelation checks user permission and add given relations tuples. // At the moment user is required be admin. -func (j *JIMM) AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { +func (j *permissionManager) AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { const op = errors.Op("jimm.AddRelation") if !user.JimmAdmin { return errors.E(op, errors.CodeUnauthorized, "unauthorized") @@ -27,7 +27,7 @@ func (j *JIMM) AddRelation(ctx context.Context, user *openfga.User, tuples []api if err != nil { return errors.E(err) } - err = j.OpenFGAClient.AddRelation(ctx, parsedTuples...) + err = j.authSvc.AddRelation(ctx, parsedTuples...) if err != nil { return errors.E(op, errors.CodeOpenFGARequestFailed, err) } @@ -36,7 +36,7 @@ func (j *JIMM) AddRelation(ctx context.Context, user *openfga.User, tuples []api // RemoveRelation checks user permission and remove given relations tuples. // At the moment user is required be admin. -func (j *JIMM) RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { +func (j *permissionManager) RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { const op = errors.Op("jimm.RemoveRelation") if !user.JimmAdmin { return errors.E(op, errors.CodeUnauthorized, "unauthorized") @@ -45,7 +45,7 @@ func (j *JIMM) RemoveRelation(ctx context.Context, user *openfga.User, tuples [] if err != nil { return errors.E(op, err) } - err = j.OpenFGAClient.RemoveRelation(ctx, parsedTuples...) + err = j.authSvc.RemoveRelation(ctx, parsedTuples...) if err != nil { return errors.E(op, errors.CodeOpenFGARequestFailed, err) } @@ -54,7 +54,7 @@ func (j *JIMM) RemoveRelation(ctx context.Context, user *openfga.User, tuples [] // CheckRelation checks user permission and return true if the given tuple exists. // At the moment user is required be admin or checking its own relations -func (j *JIMM) CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) { +func (j *permissionManager) CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) { const op = errors.Op("jimm.CheckRelation") allowed := false parsedTuple, err := j.parseTuple(ctx, tuple) @@ -67,7 +67,7 @@ func (j *JIMM) CheckRelation(ctx context.Context, user *openfga.User, tuple apip return allowed, errors.E(op, errors.CodeUnauthorized, "unauthorized") } - allowed, err = j.OpenFGAClient.CheckRelation(ctx, *parsedTuple, trace) + allowed, err = j.authSvc.CheckRelation(ctx, *parsedTuple, trace) if err != nil { return allowed, errors.E(op, errors.CodeOpenFGARequestFailed, err) } @@ -76,7 +76,7 @@ func (j *JIMM) CheckRelation(ctx context.Context, user *openfga.User, tuple apip // ListRelationshipTuples checks user permission and lists relationship tuples based of tuple struct with pagination. // Listing filters can be relaxed: optionally exclude tuple.Relation or tuple.Object or specify only tuple.TargetObject.Kind. -func (j *JIMM) ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { +func (j *permissionManager) ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { const op = errors.Op("jimm.ListRelationshipTuples") if !user.JimmAdmin { return nil, "", errors.E(op, errors.CodeUnauthorized, "unauthorized") @@ -93,7 +93,7 @@ func (j *JIMM) ListRelationshipTuples(ctx context.Context, user *openfga.User, t return nil, "", errors.E(op, errors.CodeBadRequest, "it is invalid to pass an object without a target object.") } - responseTuples, ct, err := j.OpenFGAClient.ReadRelatedObjects(ctx, *parsedTuple, pageSize, continuationToken) + responseTuples, ct, err := j.authSvc.ReadRelatedObjects(ctx, *parsedTuple, pageSize, continuationToken) if err != nil { return nil, "", errors.E(op, err) } @@ -104,7 +104,7 @@ func (j *JIMM) ListRelationshipTuples(ctx context.Context, user *openfga.User, t // Useful for listing all the resources that a group or user have access to. // // This functions provides a slightly higher-level abstraction in favor of ListRelationshipTuples. -func (j *JIMM) ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { +func (j *permissionManager) ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { const op = errors.Op("jimm.ListObjectRelations") var e pagination.EntitlementToken if !user.JimmAdmin { @@ -127,7 +127,7 @@ func (j *JIMM) ListObjectRelations(ctx context.Context, user *openfga.User, obje return responseTuples, nextToken, nil } -func (j *JIMM) getObjectRelationsPage(ctx context.Context, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { +func (j *permissionManager) getObjectRelationsPage(ctx context.Context, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { var err error var e pagination.EntitlementToken tuple := &openfga.Tuple{} @@ -147,7 +147,7 @@ func (j *JIMM) getObjectRelationsPage(ctx context.Context, object string, pageSi if err != nil { return nil, e, err } - t, nextContinuationToken, err := j.OpenFGAClient.ReadRelatedObjects(ctx, *tuple, pageSize, nextContinuationToken) + t, nextContinuationToken, err := j.authSvc.ReadRelatedObjects(ctx, *tuple, pageSize, nextContinuationToken) if err != nil { return nil, e, err } @@ -168,7 +168,7 @@ func (j *JIMM) getObjectRelationsPage(ctx context.Context, object string, pageSi // parseTuples translate the api request struct containing tuples to a slice of openfga tuple keys. // This method utilises the parseTuple method which does all the heavy lifting. -func (j *JIMM) parseTuples(ctx context.Context, tuples []apiparams.RelationshipTuple) ([]openfga.Tuple, error) { +func (j *permissionManager) parseTuples(ctx context.Context, tuples []apiparams.RelationshipTuple) ([]openfga.Tuple, error) { keys := make([]openfga.Tuple, 0, len(tuples)) for _, tuple := range tuples { key, err := j.parseTuple(ctx, tuple) @@ -183,7 +183,7 @@ func (j *JIMM) parseTuples(ctx context.Context, tuples []apiparams.RelationshipT // parseTuple takes the initial tuple from a relational request and ensures that // whatever format, be it JAAS or Juju tag, is resolved to the correct identifier // to be persisted within OpenFGA. -func (j *JIMM) parseTuple(ctx context.Context, tuple apiparams.RelationshipTuple) (*openfga.Tuple, error) { +func (j *permissionManager) parseTuple(ctx context.Context, tuple apiparams.RelationshipTuple) (*openfga.Tuple, error) { const op = errors.Op("jujuapi.parseTuple") relation, err := ofganames.ParseRelation(tuple.Relation) diff --git a/internal/jimm/relation_test.go b/internal/jimm/permissions/relations_test.go similarity index 90% rename from internal/jimm/relation_test.go rename to internal/jimm/permissions/relations_test.go index 2951d9c26..6f6c4c8f2 100644 --- a/internal/jimm/relation_test.go +++ b/internal/jimm/permissions/relations_test.go @@ -1,10 +1,9 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. -package jimm_test +package permissions_test import ( "context" - "testing" qt "github.com/frankban/quicktest" @@ -16,19 +15,16 @@ import ( apiparams "github.com/canonical/jimm/v3/pkg/api/params" ) -func TestListRelationshipTuples(t *testing.T) { - // setup - c := qt.New(t) +func (s *permissionManagerSuite) TestListRelationshipTuples(c *qt.C) { + c.Parallel() ctx := context.Background() - j := jimmtest.NewJIMM(c, nil) - - u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, j.OpenFGAClient) + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, s.ofgaClient) u.JimmAdmin = true - user, _, controller, model, _, _, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) + user, _, controller, model, _, _, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, s.db) - err := j.AddRelation(ctx, u, []apiparams.RelationshipTuple{ + err := s.manager.AddRelation(ctx, u, []apiparams.RelationshipTuple{ { Object: user.Tag().String(), Relation: names.ReaderRelation.String(), @@ -46,6 +42,7 @@ func TestListRelationshipTuples(t *testing.T) { }, }) c.Assert(err, qt.IsNil) + type ExpectedTuple struct { expectedRelation string expectedTargetId string @@ -66,7 +63,7 @@ func TestListRelationshipTuples(t *testing.T) { relation: "", targetObject: "", expectedError: nil, - expectedLength: 3, + expectedLength: 4, }, { description: "test listing a specific relation", @@ -137,7 +134,7 @@ func TestListRelationshipTuples(t *testing.T) { for _, t := range testCases { c.Run(t.description, func(c *qt.C) { - tuples, _, err := j.ListRelationshipTuples(ctx, u, apiparams.RelationshipTuple{ + tuples, _, err := s.manager.ListRelationshipTuples(ctx, s.adminUser, apiparams.RelationshipTuple{ Object: t.object, Relation: t.relation, TargetObject: t.targetObject, @@ -152,18 +149,16 @@ func TestListRelationshipTuples(t *testing.T) { } } -func TestListObjectRelations(t *testing.T) { - c := qt.New(t) +func (s *permissionManagerSuite) TestListObjectRelations(c *qt.C) { + c.Parallel() ctx := context.Background() - j := jimmtest.NewJIMM(c, nil) - - u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, j.OpenFGAClient) + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, s.ofgaClient) u.JimmAdmin = true - user, group, controller, model, _, cloud, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) + user, group, controller, model, _, cloud, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, s.db) - err := j.AddRelation(ctx, u, []apiparams.RelationshipTuple{ + err := s.manager.AddRelation(ctx, u, []apiparams.RelationshipTuple{ { Object: user.Tag().String(), Relation: names.ReaderRelation.String(), @@ -250,7 +245,7 @@ func TestListObjectRelations(t *testing.T) { tuples := []openfga.Tuple{} numPages := 0 for { - res, nextToken, err := j.ListObjectRelations(ctx, u, t.object, t.pageSize, token) + res, nextToken, err := s.manager.ListObjectRelations(ctx, s.adminUser, t.object, t.pageSize, token) if t.expectedError != "" { c.Assert(err, qt.ErrorMatches, t.expectedError) break diff --git a/internal/jimm/permissions/suite_test.go b/internal/jimm/permissions/suite_test.go new file mode 100644 index 000000000..1e5251642 --- /dev/null +++ b/internal/jimm/permissions/suite_test.go @@ -0,0 +1,71 @@ +// Copyright 2025 Canonical. + +package permissions_test + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/frankban/quicktest/qtsuite" + "github.com/google/uuid" + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimm/permissions" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/testutils/jimmtest" +) + +type permissionManagerSuite struct { + manager *permissions.PermissionManager + adminUser *openfga.User + user *openfga.User + db *db.Database + ctlTag names.ControllerTag + ofgaClient *openfga.OFGAClient +} + +func (s *permissionManagerSuite) Init(c *qt.C) { + ctx := context.Background() + + db := &db.Database{ + DB: jimmtest.PostgresDB(c, time.Now), + } + err := db.Migrate(context.Background()) + c.Assert(err, qt.IsNil) + + s.db = db + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + s.ofgaClient = ofgaClient + + uuid := uuid.New() + ctlTag := names.NewControllerTag(uuid.String()) + s.ctlTag = ctlTag + + s.manager, err = permissions.NewManager(db, ofgaClient, uuid.String(), ctlTag) + c.Assert(err, qt.IsNil) + + // Create test identity + i, err := dbmodel.NewIdentity("alice") + c.Assert(err, qt.IsNil) + s.adminUser = openfga.NewUser(i, ofgaClient) + s.adminUser.JimmAdmin = true + + err = s.adminUser.SetControllerAccess(ctx, ctlTag, ofganames.AdministratorRelation) + c.Assert(err, qt.IsNil) + + i2, err := dbmodel.NewIdentity("bob") + c.Assert(err, qt.IsNil) + s.user = openfga.NewUser(i2, ofgaClient) +} + +func TestPermissionManager(t *testing.T) { + qtsuite.Run(qt.New(t), &permissionManagerSuite{}) +} diff --git a/internal/jimm/access.go b/internal/jimm/permissions/tagresolver.go similarity index 58% rename from internal/jimm/access.go rename to internal/jimm/permissions/tagresolver.go index de85a10a8..849a602a2 100644 --- a/internal/jimm/access.go +++ b/internal/jimm/permissions/tagresolver.go @@ -1,6 +1,6 @@ -// Copyright 2024 Canonical. +// Copyright 2025 Canonical. -package jimm +package permissions import ( "context" @@ -11,7 +11,6 @@ import ( "github.com/canonical/ofga" "github.com/google/uuid" - jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" @@ -19,9 +18,7 @@ import ( "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" - "github.com/canonical/jimm/v3/internal/servermon" jimmnames "github.com/canonical/jimm/v3/pkg/names" ) @@ -50,183 +47,9 @@ var ( modelOwnerAndNameMatcher = regexp.MustCompile(`(.+)/(.+)`) ) -// ToOfferAccessString maps relation to an application offer access string. -func ToOfferAccessString(relation openfga.Relation) string { - switch relation { - case ofganames.AdministratorRelation: - return string(jujuparams.OfferAdminAccess) - case ofganames.ConsumerRelation: - return string(jujuparams.OfferConsumeAccess) - case ofganames.ReaderRelation: - return string(jujuparams.OfferReadAccess) - default: - return "" - } -} - -// ToCloudAccessString maps relation to a cloud access string. -func ToCloudAccessString(relation openfga.Relation) string { - switch relation { - case ofganames.AdministratorRelation: - return "admin" - case ofganames.CanAddModelRelation: - return "add-model" - default: - return "" - } -} - -// ToModelAccessString maps relation to a model access string. -func ToModelAccessString(relation openfga.Relation) string { - switch relation { - case ofganames.AdministratorRelation: - return "admin" - case ofganames.WriterRelation: - return "write" - case ofganames.ReaderRelation: - return "read" - default: - return "" - } -} - -// ToModelAccessString maps relation to a controller access string. -func ToControllerAccessString(relation openfga.Relation) string { - switch relation { - case ofganames.AdministratorRelation: - return "superuser" - default: - return "login" - } -} - -// ToCloudRelation returns a valid relation for the cloud. Access level -// string can be either "admin", in which case the administrator relation -// is returned, or "add-model", in which case the can_addmodel relation is -// returned. -func ToCloudRelation(accessLevel string) (openfga.Relation, error) { - switch accessLevel { - case "admin": - return ofganames.AdministratorRelation, nil - case "add-model": - return ofganames.CanAddModelRelation, nil - default: - return ofganames.NoRelation, errors.E("unknown cloud access") - } -} - -// ToModelRelation returns a valid relation for the model. -func ToModelRelation(accessLevel string) (openfga.Relation, error) { - switch accessLevel { - case "admin": - return ofganames.AdministratorRelation, nil - case "write": - return ofganames.WriterRelation, nil - case "read": - return ofganames.ReaderRelation, nil - default: - return ofganames.NoRelation, errors.E("unknown model access") - } -} - -// ToOfferRelation returns a valid relation for the application offer. -func ToOfferRelation(accessLevel string) (openfga.Relation, error) { - switch accessLevel { - case "": - return ofganames.NoRelation, nil - case string(jujuparams.OfferAdminAccess): - return ofganames.AdministratorRelation, nil - case string(jujuparams.OfferConsumeAccess): - return ofganames.ConsumerRelation, nil - case string(jujuparams.OfferReadAccess): - return ofganames.ReaderRelation, nil - default: - return ofganames.NoRelation, errors.E("unknown application offer access") - } -} - -// CheckPermission loops over the desired permissions in desiredPerms and adds these permissions -// to cachedPerms if they exist. If the user does not have any of the desired permissions then an -// error is returned. -// Note that cachedPerms map is modified and returned. -func (j *JIMM) CheckPermission(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) { - const op = errors.Op("jimm.CheckPermission") - for key, val := range desiredPerms { - if _, ok := cachedPerms[key]; !ok { - stringVal, ok := val.(string) - if !ok { - return nil, errors.E(op, fmt.Sprintf("failed to get permission assertion: expected %T, got %T", stringVal, val)) - } - tag, err := names.ParseTag(key) - if err != nil { - return cachedPerms, errors.E(op, fmt.Sprintf("failed to parse tag %s", key)) - } - relation, err := ofganames.ConvertJujuRelation(stringVal) - if err != nil { - return cachedPerms, errors.E(op, fmt.Sprintf("failed to parse relation %s", stringVal), err) - } - check, err := openfga.CheckRelation(ctx, user, tag, relation) - if err != nil { - return cachedPerms, errors.E(op, err) - } - if !check { - return cachedPerms, errors.E(op, fmt.Sprintf("Missing permission for %s:%s", key, val)) - } - cachedPerms[key] = stringVal - } - } - return cachedPerms, nil -} - -// GrantAuditLogAccess grants audit log access for the target user. -func (j *JIMM) GrantAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { - const op = errors.Op("jimm.GrantAuditLogAccess") - - access := user.GetControllerAccess(ctx, j.ResourceTag()) - if access != ofganames.AdministratorRelation { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - targetUser := &dbmodel.Identity{} - targetUser.SetTag(targetUserTag) - err := j.Database.GetIdentity(ctx, targetUser) - if err != nil { - return errors.E(op, err) - } - - err = openfga.NewUser(targetUser, j.OpenFGAClient).SetControllerAccess(ctx, j.ResourceTag(), ofganames.AuditLogViewerRelation) - if err != nil { - return errors.E(op, err) - } - return nil -} - -// RevokeAuditLogAccess revokes audit log access for the target user. -func (j *JIMM) RevokeAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { - const op = errors.Op("jimm.RevokeAuditLogAccess") - - access := user.GetControllerAccess(ctx, j.ResourceTag()) - if access != ofganames.AdministratorRelation { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - targetUser := &dbmodel.Identity{} - targetUser.SetTag(targetUserTag) - err := j.Database.GetIdentity(ctx, targetUser) - if err != nil { - return errors.E(op, err) - } - - err = openfga.NewUser(targetUser, j.OpenFGAClient).UnsetAuditLogViewerAccess(ctx, j.ResourceTag()) - if err != nil { - return errors.E(op, err) - } - return nil -} - // ToJAASTag converts a tag used in OpenFGA authorization model to a // tag used in JAAS. -func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) { +func (j *permissionManager) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) { if !resolveUUIDs { res := tag.Kind.String() + "-" + tag.ID if tag.Relation.String() != "" { @@ -249,13 +72,13 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b case jimmnames.ServiceAccountTagKind: return jimmnames.ServiceAccountTagKind + "-" + tag.ID, nil case names.ControllerTagKind: - if tag.ID == j.ResourceTag().Id() { + if tag.ID == j.jimmTag.Id() { return "controller-jimm", nil } controller := dbmodel.Controller{ UUID: tag.ID, } - err := j.Database.GetController(ctx, &controller) + err := j.store.GetController(ctx, &controller) if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch controller information: %s", controller.UUID)) } @@ -267,7 +90,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b Valid: true, }, } - err := j.Database.GetModel(ctx, &model) + err := j.store.GetModel(ctx, &model) if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch model information: %s", model.UUID.String)) } @@ -277,7 +100,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b ao := dbmodel.ApplicationOffer{ UUID: tag.ID, } - err := j.Database.GetApplicationOffer(ctx, &ao) + err := j.store.GetApplicationOffer(ctx, &ao) if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch application offer information: %s", ao.UUID)) } @@ -286,7 +109,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b group := dbmodel.GroupEntry{ UUID: tag.ID, } - err := j.Database.GetGroup(ctx, &group) + err := j.store.GetGroup(ctx, &group) if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch group information: %s", group.UUID)) } @@ -295,7 +118,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b role := dbmodel.RoleEntry{ UUID: tag.ID, } - err := j.Database.GetRole(ctx, &role) + err := j.store.GetRole(ctx, &role) if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch role information: %s", role.UUID)) } @@ -304,7 +127,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b cloud := dbmodel.Cloud{ Name: tag.ID, } - err := j.Database.GetCloud(ctx, &cloud) + err := j.store.GetCloud(ctx, &cloud) if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch cloud information: %s", cloud.Name)) } @@ -382,25 +205,6 @@ func (t *tagResolver) groupTag(ctx context.Context, db *db.Database) (*ofga.Enti return ofganames.ConvertTagWithRelation(entry.ResourceTag(), t.relation), nil } -func (t *tagResolver) roleTag(ctx context.Context, db *db.Database) (*ofga.Entity, error) { - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: role", - zap.String("role-name", t.trailer), - ) - if t.resourceUUID != "" { - return ofganames.ConvertTagWithRelation(jimmnames.NewRoleTag(t.resourceUUID), t.relation), nil - } - entry := dbmodel.RoleEntry{Name: t.trailer} - - err := db.GetRole(ctx, &entry) - if err != nil { - return nil, errors.E(fmt.Sprintf("role %s not found", t.trailer)) - } - - return ofganames.ConvertTagWithRelation(entry.ResourceTag(), t.relation), nil -} - func (t *tagResolver) controllerTag(ctx context.Context, jimmUUID string, db *db.Database) (*ofga.Entity, error) { zapctx.Debug( ctx, @@ -422,6 +226,25 @@ func (t *tagResolver) controllerTag(ctx context.Context, jimmUUID string, db *db return ofganames.ConvertTagWithRelation(controller.ResourceTag(), t.relation), nil } +func (t *tagResolver) roleTag(ctx context.Context, db *db.Database) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: role", + zap.String("role-name", t.trailer), + ) + if t.resourceUUID != "" { + return ofganames.ConvertTagWithRelation(jimmnames.NewRoleTag(t.resourceUUID), t.relation), nil + } + entry := dbmodel.RoleEntry{Name: t.trailer} + + err := db.GetRole(ctx, &entry) + if err != nil { + return nil, errors.E(fmt.Sprintf("role %s not found", t.trailer)) + } + + return ofganames.ConvertTagWithRelation(entry.ResourceTag(), t.relation), nil +} + func (t *tagResolver) modelTag(ctx context.Context, db *db.Database) (*ofga.Entity, error) { zapctx.Debug( ctx, @@ -538,7 +361,7 @@ func resolveTag(jimmUUID string, db *db.Database, tag string) (*ofganames.Tag, e // ensuring the resource exists for said tag. // // This key may be in the form of either a JIMM tag string or Juju tag string. -func (j *JIMM) parseAndValidateTag(ctx context.Context, key string) (*ofganames.Tag, error) { +func (j *permissionManager) parseAndValidateTag(ctx context.Context, key string) (*ofganames.Tag, error) { op := errors.Op("jimm.parseAndValidateTag") tupleKeySplit := strings.SplitN(key, "-", 2) if len(tupleKeySplit) == 1 { @@ -549,7 +372,7 @@ func (j *JIMM) parseAndValidateTag(ctx context.Context, key string) (*ofganames. return tag, nil } tagString := key - tag, err := resolveTag(j.UUID, j.Database, tagString) + tag, err := resolveTag(j.jimmUUID, j.store, tagString) if err != nil { zapctx.Debug(ctx, "failed to resolve tuple object", zap.Error(err)) return nil, errors.E(op, errors.CodeFailedToResolveTupleResource, err) @@ -558,65 +381,3 @@ func (j *JIMM) parseAndValidateTag(ctx context.Context, key string) (*ofganames. return tag, nil } - -// OpenFGACleanup queries OpenFGA for all existing tuples, tries to resolve each tuple and removes those -// that JIMM cannot resolved - orphaned tuples. JIMM not being able to resolve a tuple means that the -// corresponding entity has been removed from JIMM's database. -// -// This approach to cleaning up tuples is intended to be temporary while we implement -// a better approach to eventual consistency of JIMM's database objects and OpenFGA tuples. -func (j *JIMM) OpenFGACleanup(ctx context.Context) (err error) { - const op = errors.Op("jimm.CleanupDyingModels") - zapctx.Info(ctx, string(op)) - durationObserver := servermon.DurationObserver(servermon.JimmMethodsDurationHistogram, string(op)) - defer durationObserver() - var ( - continuationToken string - tuples []ofga.Tuple - ) - for { - tuples, continuationToken, err = j.OpenFGAClient.ReadRelatedObjects(ctx, openfga.Tuple{}, 20, continuationToken) - if err != nil { - zapctx.Error(ctx, "reading all tuples", zap.Error(err)) - return err - } - - orphanedTuples := j.orphanedTuples(ctx, tuples...) - if len(orphanedTuples) > 0 { - zapctx.Debug(ctx, "removing orphaned tuples", zap.Any("tuples", orphanedTuples)) - err = j.OpenFGAClient.RemoveRelation(ctx, orphanedTuples...) - if err != nil { - zapctx.Warn(ctx, "failed to clean up orphaned tuples", zap.Error(err)) - } - } - if continuationToken == "" { - return nil - } - select { - case <-ctx.Done(): - return nil - default: - } - } -} - -func (j *JIMM) orphanedTuples(ctx context.Context, tuples ...openfga.Tuple) []openfga.Tuple { - orphanedTuples := []openfga.Tuple{} - for _, tuple := range tuples { - _, err := j.ToJAASTag(ctx, tuple.Object, true) - if err != nil { - if errors.ErrorCode(err) == errors.CodeNotFound { - orphanedTuples = append(orphanedTuples, tuple) - continue - } - } - _, err = j.ToJAASTag(ctx, tuple.Target, true) - if err != nil { - if errors.ErrorCode(err) == errors.CodeNotFound { - orphanedTuples = append(orphanedTuples, tuple) - continue - } - } - } - return orphanedTuples -} diff --git a/internal/jimm/service_account.go b/internal/jimm/service_account.go index c4f5bbdeb..98a7fcf79 100644 --- a/internal/jimm/service_account.go +++ b/internal/jimm/service_account.go @@ -8,8 +8,6 @@ import ( jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/juju/zaputil/zapctx" - "go.uber.org/zap" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/openfga" @@ -89,41 +87,3 @@ func (j *JIMM) CopyServiceAccountCredential(ctx context.Context, u *openfga.User }) return newTag, modelRes, err } - -// GrantServiceAccountAccess creates an administrator relation between the tags provided -// and the service account. The provided tags must be users or groups (with the member relation) -// otherwise OpenFGA will report an error. -func (j *JIMM) GrantServiceAccountAccess(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error { - op := errors.Op("jimm.GrantServiceAccountAccess") - tags := make([]*ofganames.Tag, 0, len(entities)) - // Validate tags - for _, val := range entities { - tag, err := j.parseAndValidateTag(ctx, val) - if err != nil { - return errors.E(op, err) - } - if tag.Kind != openfga.UserType && tag.Kind != openfga.GroupType { - return errors.E(op, "invalid entity - not user or group") - } - if tag.Kind == openfga.GroupType { - tag.Relation = ofganames.MemberRelation - } - tags = append(tags, tag) - } - tuples := make([]openfga.Tuple, 0, len(tags)) - svcAccEntity := ofganames.ConvertTag(svcAccTag) - for _, tag := range tags { - tuple := openfga.Tuple{ - Object: tag, - Relation: ofganames.AdministratorRelation, - Target: svcAccEntity, - } - tuples = append(tuples, tuple) - } - err := j.OpenFGAClient.AddRelation(ctx, tuples...) - if err != nil { - zapctx.Error(ctx, "failed to add tuple(s)", zap.NamedError("add-relation-error", err)) - return errors.E(op, errors.CodeOpenFGARequestFailed, err) - } - return nil -} diff --git a/internal/jimm/service_account_test.go b/internal/jimm/service_account_test.go index 6e09ee628..c2b9b1495 100644 --- a/internal/jimm/service_account_test.go +++ b/internal/jimm/service_account_test.go @@ -8,14 +8,12 @@ import ( qt "github.com/frankban/quicktest" jujuparams "github.com/juju/juju/rpc/params" - "github.com/juju/names/v5" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/testutils/jimmtest" - jimmnames "github.com/canonical/jimm/v3/pkg/names" ) func TestAddServiceAccount(t *testing.T) { @@ -153,95 +151,3 @@ func TestCopyServiceAccountCredentialWithMissingCredential(t *testing.T) { _, _, err = j.CopyServiceAccountCredential(ctx, user, svcAcc, cred.ResourceTag()) c.Assert(err, qt.ErrorMatches, "cloudcredential .* not found") } - -func TestGrantServiceAccountAccess(t *testing.T) { - c := qt.New(t) - - tests := []struct { - about string - grantServiceAccountAccess func(ctx context.Context, user *openfga.User, tags []string) error - clientID string - tags []string - username string - addGroups []string - expectedError string - }{{ - about: "Valid request", - grantServiceAccountAccess: func(ctx context.Context, user *openfga.User, tags []string) error { - return nil - }, - addGroups: []string{"1"}, - tags: []string{ - "user-alice", - "user-bob", - "group-1#member", - }, - clientID: "fca1f605-736e-4d1f-bcd2-aecc726923be@serviceaccount", - username: "alice", - }, { - about: "Group that doesn't exist", - grantServiceAccountAccess: func(ctx context.Context, user *openfga.User, tags []string) error { - return nil - }, - tags: []string{ - "user-alice", - "user-bob", - // This group doesn't exist. - "group-bar", - }, - clientID: "fca1f605-736e-4d1f-bcd2-aecc726923be@serviceaccount", - username: "alice", - expectedError: "group bar not found", - }, { - about: "Invalid tags", - grantServiceAccountAccess: func(ctx context.Context, user *openfga.User, tags []string) error { - return nil - }, - tags: []string{ - "user-alice", - "user-bob", - "controller-jimm", - }, - clientID: "fca1f605-736e-4d1f-bcd2-aecc726923be@serviceaccount", - username: "alice", - expectedError: "invalid entity - not user or group", - }} - - for _, test := range tests { - test := test - c.Run(test.about, func(c *qt.C) { - j := jimmtest.NewJIMM(c, nil) - - var u dbmodel.Identity - u.SetTag(names.NewUserTag(test.clientID)) - svcAccountIdentity := openfga.NewUser(&u, j.OpenFGAClient) - svcAccountIdentity.JimmAdmin = true - if len(test.addGroups) > 0 { - for _, name := range test.addGroups { - _, err := j.GroupManager().AddGroup(context.Background(), svcAccountIdentity, name) - c.Assert(err, qt.IsNil) - } - } - svcAccountTag := jimmnames.NewServiceAccountTag(test.clientID) - - err := j.GrantServiceAccountAccess(context.Background(), svcAccountIdentity, svcAccountTag, test.tags) - if test.expectedError == "" { - c.Assert(err, qt.IsNil) - for _, tag := range test.tags { - parsedTag, err := j.ParseAndValidateTag(context.Background(), tag) - c.Assert(err, qt.IsNil) - tuple := openfga.Tuple{ - Object: parsedTag, - Relation: ofganames.AdministratorRelation, - Target: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(test.clientID)), - } - ok, err := j.OpenFGAClient.CheckRelation(context.Background(), tuple, false) - c.Assert(err, qt.IsNil) - c.Assert(ok, qt.IsTrue) - } - } else { - c.Assert(err, qt.ErrorMatches, test.expectedError) - } - }) - } -} diff --git a/internal/jimmhttp/rebac_admin/groups.go b/internal/jimmhttp/rebac_admin/groups.go index f0a6e8508..eaf1dae0e 100644 --- a/internal/jimmhttp/rebac_admin/groups.go +++ b/internal/jimmhttp/rebac_admin/groups.go @@ -164,7 +164,7 @@ func (s *groupsService) GetGroupIdentities(ctx context.Context, groupId string, Relation: ofganames.MemberRelation.String(), TargetObject: groupTag.String(), } - identities, nextToken, err := s.jimm.ListRelationshipTuples(ctx, user, tuple, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion + identities, nextToken, err := s.jimm.PermissionManager().ListRelationshipTuples(ctx, user, tuple, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion if err != nil { return nil, err } @@ -221,13 +221,13 @@ func (s *groupsService) PatchGroupIdentities(ctx context.Context, groupId string } } if toAdd != nil { - err := s.jimm.AddRelation(ctx, user, toAdd) + err := s.jimm.PermissionManager().AddRelation(ctx, user, toAdd) if err != nil { return false, err } } if toRemove != nil { - err := s.jimm.RemoveRelation(ctx, user, toRemove) + err := s.jimm.PermissionManager().RemoveRelation(ctx, user, toRemove) if err != nil { return false, err } @@ -261,7 +261,7 @@ func (s *groupsService) GetGroupRoles(ctx context.Context, groupId string, param Relation: ofganames.AssigneeRelation.String(), TargetObject: openfga.RoleType.String(), } - roles, nextToken, err := s.jimm.ListRelationshipTuples(ctx, user, tuple, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion + roles, nextToken, err := s.jimm.PermissionManager().ListRelationshipTuples(ctx, user, tuple, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion if err != nil { return nil, err } @@ -335,13 +335,13 @@ func (s *groupsService) PatchGroupRoles(ctx context.Context, groupId string, rol } if toAdd != nil { - err := s.jimm.AddRelation(ctx, user, toAdd) + err := s.jimm.PermissionManager().AddRelation(ctx, user, toAdd) if err != nil { return false, err } } if toRemove != nil { - err := s.jimm.RemoveRelation(ctx, user, toRemove) + err := s.jimm.PermissionManager().RemoveRelation(ctx, user, toRemove) if err != nil { return false, err } @@ -363,7 +363,7 @@ func (s *groupsService) GetGroupEntitlements(ctx context.Context, groupId string group := ofganames.WithMemberRelation(jimmnames.NewGroupTag(groupId)) entitlementToken := pagination.NewEntitlementToken(filter.Token()) // nolint:gosec accept integer conversion - tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, group, int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion + tuples, nextEntitlmentToken, err := s.jimm.PermissionManager().ListObjectRelations(ctx, user, group, int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion if err != nil { return nil, err } @@ -424,13 +424,13 @@ func (s *groupsService) PatchGroupEntitlements(ctx context.Context, groupId stri return false, err } if toAdd != nil { - err := s.jimm.AddRelation(ctx, user, toAdd) + err := s.jimm.PermissionManager().AddRelation(ctx, user, toAdd) if err != nil { return false, err } } if toRemove != nil { - err := s.jimm.RemoveRelation(ctx, user, toRemove) + err := s.jimm.PermissionManager().RemoveRelation(ctx, user, toRemove) if err != nil { return false, err } diff --git a/internal/jimmhttp/rebac_admin/groups_test.go b/internal/jimmhttp/rebac_admin/groups_test.go index 11ec31e4e..8f4eb58fc 100644 --- a/internal/jimmhttp/rebac_admin/groups_test.go +++ b/internal/jimmhttp/rebac_admin/groups_test.go @@ -173,14 +173,17 @@ func TestGetGroupIdentities(t *testing.T) { return nil, getGroupErr }, } + permissionManager := mocks.PermissionManager{ + ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, ct string) ([]openfga.Tuple, string, error) { + return []openfga.Tuple{testTuple}, continuationToken, listTuplesErr + }, + } jimm := jimmtest.JIMM{ GroupManager_: func() jimm.GroupManager { return &groupManager }, - RelationService: mocks.RelationService{ - ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, ct string) ([]openfga.Tuple, string, error) { - return []openfga.Tuple{testTuple}, continuationToken, listTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} @@ -218,14 +221,17 @@ func TestGetGroupIdentities(t *testing.T) { func TestPatchGroupIdentities(t *testing.T) { c := qt.New(t) var patchTuplesErr error + permissionManager := mocks.PermissionManager{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + } jimm := jimmtest.JIMM{ - RelationService: mocks.RelationService{ - AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, - RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} @@ -278,6 +284,11 @@ func TestGetGroupRoles(t *testing.T) { return nil, getGroupErr }, } + permissionManager := mocks.PermissionManager{ + ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, ct string) ([]openfga.Tuple, string, error) { + return []openfga.Tuple{testTuple}, continuationToken, listTuplesErr + }, + } jimm := jimmtest.JIMM{ RoleManager_: func() jimm.RoleManager { return roleManager @@ -285,10 +296,8 @@ func TestGetGroupRoles(t *testing.T) { GroupManager_: func() jimm.GroupManager { return &groupManager }, - RelationService: mocks.RelationService{ - ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, ct string) ([]openfga.Tuple, string, error) { - return []openfga.Tuple{testTuple}, continuationToken, listTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } @@ -337,14 +346,17 @@ func TestGetGroupRoles(t *testing.T) { func TestPatchGroupRoles(t *testing.T) { c := qt.New(t) var patchTuplesErr error + permissionManager := mocks.PermissionManager{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + } jimm := jimmtest.JIMM{ - RelationService: mocks.RelationService{ - AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, - RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} @@ -384,11 +396,14 @@ func TestGetGroupEntitlements(t *testing.T) { Relation: ofga.Relation("member"), Target: &ofga.Entity{Kind: "group", ID: "my-group"}, } + permissionManager := mocks.PermissionManager{ + ListObjectRelations_: func(ctx context.Context, user *openfga.User, object string, pageSize int32, ct pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { + return []openfga.Tuple{testTuple}, pagination.NewEntitlementToken(continuationToken), listRelationsErr + }, + } jimm := jimmtest.JIMM{ - RelationService: mocks.RelationService{ - ListObjectRelations_: func(ctx context.Context, user *openfga.User, object string, pageSize int32, ct pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { - return []openfga.Tuple{testTuple}, pagination.NewEntitlementToken(continuationToken), listRelationsErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} @@ -425,14 +440,17 @@ func TestGetGroupEntitlements(t *testing.T) { func TestPatchGroupEntitlements(t *testing.T) { c := qt.New(t) var patchTuplesErr error + permissionManager := mocks.PermissionManager{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + } jimm := jimmtest.JIMM{ - RelationService: mocks.RelationService{ - AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, - RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} diff --git a/internal/jimmhttp/rebac_admin/identities.go b/internal/jimmhttp/rebac_admin/identities.go index 709c6e4da..ebb860567 100644 --- a/internal/jimmhttp/rebac_admin/identities.go +++ b/internal/jimmhttp/rebac_admin/identities.go @@ -109,7 +109,7 @@ func (s *identitiesService) GetIdentityRoles(ctx context.Context, identityId str return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) } filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) - tuples, cNextToken, err := s.jimm.ListRelationshipTuples(ctx, user, apiparams.RelationshipTuple{ + tuples, cNextToken, err := s.jimm.PermissionManager().ListRelationshipTuples(ctx, user, apiparams.RelationshipTuple{ Object: objUser.ResourceTag().String(), Relation: ofganames.AssigneeRelation.String(), TargetObject: openfga.RoleType.String(), @@ -178,14 +178,14 @@ func (s *identitiesService) PatchIdentityRoles(ctx context.Context, identityId s } } if len(additions) > 0 { - err = s.jimm.AddRelation(ctx, user, additions) + err = s.jimm.PermissionManager().AddRelation(ctx, user, additions) if err != nil { zapctx.Error(context.Background(), "cannot add relations", zap.Error(err)) return false, v1.NewUnknownError(err.Error()) } } if len(deletions) > 0 { - err = s.jimm.RemoveRelation(ctx, user, deletions) + err = s.jimm.PermissionManager().RemoveRelation(ctx, user, deletions) if err != nil { zapctx.Error(context.Background(), "cannot remove relations", zap.Error(err)) return false, v1.NewUnknownError(err.Error()) @@ -205,7 +205,7 @@ func (s *identitiesService) GetIdentityGroups(ctx context.Context, identityId st return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) } filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) - tuples, cNextToken, err := s.jimm.ListRelationshipTuples(ctx, user, apiparams.RelationshipTuple{ + tuples, cNextToken, err := s.jimm.PermissionManager().ListRelationshipTuples(ctx, user, apiparams.RelationshipTuple{ Object: objUser.ResourceTag().String(), Relation: ofganames.MemberRelation.String(), TargetObject: openfga.GroupType.String(), @@ -274,14 +274,14 @@ func (s *identitiesService) PatchIdentityGroups(ctx context.Context, identityId } } if len(additions) > 0 { - err = s.jimm.AddRelation(ctx, user, additions) + err = s.jimm.PermissionManager().AddRelation(ctx, user, additions) if err != nil { zapctx.Error(context.Background(), "cannot add relations", zap.Error(err)) return false, v1.NewUnknownError(err.Error()) } } if len(deletions) > 0 { - err = s.jimm.RemoveRelation(ctx, user, deletions) + err = s.jimm.PermissionManager().RemoveRelation(ctx, user, deletions) if err != nil { zapctx.Error(context.Background(), "cannot remove relations", zap.Error(err)) return false, v1.NewUnknownError(err.Error()) @@ -306,7 +306,7 @@ func (s *identitiesService) GetIdentityEntitlements(ctx context.Context, identit filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) entitlementToken := pagination.NewEntitlementToken(filter.Token()) - tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, objUser.Tag().String(), int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion + tuples, nextEntitlmentToken, err := s.jimm.PermissionManager().ListObjectRelations(ctx, user, objUser.Tag().String(), int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion if err != nil { return nil, err } @@ -367,13 +367,13 @@ func (s *identitiesService) PatchIdentityEntitlements(ctx context.Context, ident return false, err } if toAdd != nil { - err := s.jimm.AddRelation(ctx, user, toAdd) + err := s.jimm.PermissionManager().AddRelation(ctx, user, toAdd) if err != nil { return false, err } } if toRemove != nil { - err := s.jimm.RemoveRelation(ctx, user, toRemove) + err := s.jimm.PermissionManager().RemoveRelation(ctx, user, toRemove) if err != nil { return false, err } diff --git a/internal/jimmhttp/rebac_admin/identities_integration_test.go b/internal/jimmhttp/rebac_admin/identities_integration_test.go index 5a00dedb2..3a9559503 100644 --- a/internal/jimmhttp/rebac_admin/identities_integration_test.go +++ b/internal/jimmhttp/rebac_admin/identities_integration_test.go @@ -76,7 +76,7 @@ func (s *identitiesSuite) TestIdentityPatchGroups(c *gc.C) { // test user added to groups objUser, err := s.JIMM.IdentityManager().FetchIdentity(ctx, username) c.Assert(err, gc.IsNil) - tuples, _, err := s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + tuples, _, err := s.JIMM.PermissionManager().ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ Object: objUser.ResourceTag().String(), Relation: ofganames.MemberRelation.String(), TargetObject: group.ResourceTag().String(), @@ -92,7 +92,7 @@ func (s *identitiesSuite) TestIdentityPatchGroups(c *gc.C) { }}) c.Assert(err, gc.IsNil) c.Assert(changed, gc.Equals, true) - tuples, _, err = s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + tuples, _, err = s.JIMM.PermissionManager().ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ Object: objUser.ResourceTag().String(), Relation: ofganames.MemberRelation.String(), TargetObject: group.ResourceTag().String(), @@ -206,7 +206,7 @@ func (s *identitiesSuite) TestIdentityPatchRoles(c *gc.C) { // test user added to roles objUser, err := s.JIMM.IdentityManager().FetchIdentity(ctx, username) c.Assert(err, gc.IsNil) - tuples, _, err := s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + tuples, _, err := s.JIMM.PermissionManager().ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ Object: objUser.ResourceTag().String(), Relation: ofganames.AssigneeRelation.String(), TargetObject: role.ResourceTag().String(), @@ -222,7 +222,7 @@ func (s *identitiesSuite) TestIdentityPatchRoles(c *gc.C) { }}) c.Assert(err, gc.IsNil) c.Assert(changed, gc.Equals, true) - tuples, _, err = s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + tuples, _, err = s.JIMM.PermissionManager().ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ Object: objUser.ResourceTag().String(), Relation: ofganames.AssigneeRelation.String(), TargetObject: role.ResourceTag().String(), diff --git a/internal/jimmhttp/rebac_admin/identities_test.go b/internal/jimmhttp/rebac_admin/identities_test.go index 0b9a068b5..22d9ff169 100644 --- a/internal/jimmhttp/rebac_admin/identities_test.go +++ b/internal/jimmhttp/rebac_admin/identities_test.go @@ -175,14 +175,17 @@ func TestGetIdentityGroups(t *testing.T) { return nil, dbmodel.IdentityCreationError }, } + permissionManager := mocks.PermissionManager{ + ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { + return []openfga.Tuple{testTuple}, "continuation-token", listTuplesErr + }, + } jimm := jimmtest.JIMM{ IdentityManager_: func() jimm.IdentityManager { return &identityManager }, - RelationService: mocks.RelationService{ - ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { - return []openfga.Tuple{testTuple}, "continuation-token", listTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, GroupManager_: func() jimm.GroupManager { return &groupManager @@ -221,17 +224,20 @@ func TestPatchIdentityGroups(t *testing.T) { return nil, dbmodel.IdentityCreationError }, } + permissionManager := mocks.PermissionManager{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + } jimm := jimmtest.JIMM{ IdentityManager_: func() jimm.IdentityManager { return &identityManager }, - RelationService: mocks.RelationService{ - AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, - RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} @@ -285,14 +291,17 @@ func TestGetIdentityRoles(t *testing.T) { return nil, dbmodel.IdentityCreationError }, } + permissionManager := mocks.PermissionManager{ + ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { + return []openfga.Tuple{testTuple}, "continuation-token", listTuplesErr + }, + } jimm := jimmtest.JIMM{ IdentityManager_: func() jimm.IdentityManager { return &identityManager }, - RelationService: mocks.RelationService{ - ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { - return []openfga.Tuple{testTuple}, "continuation-token", listTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, RoleManager_: func() jimm.RoleManager { return roleManager @@ -331,17 +340,20 @@ func TestPatchIdentityRoles(t *testing.T) { return nil, dbmodel.IdentityCreationError }, } + permissionManager := mocks.PermissionManager{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + } jimm := jimmtest.JIMM{ IdentityManager_: func() jimm.IdentityManager { return &identityManager }, - RelationService: mocks.RelationService{ - AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, - RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} diff --git a/internal/jimmhttp/rebac_admin/roles.go b/internal/jimmhttp/rebac_admin/roles.go index 94349eb6a..efea142b5 100644 --- a/internal/jimmhttp/rebac_admin/roles.go +++ b/internal/jimmhttp/rebac_admin/roles.go @@ -154,7 +154,7 @@ func (s *rolesService) GetRoleEntitlements(ctx context.Context, roleId string, p role := ofganames.WithAssigneeRelation(jimmnames.NewRoleTag(roleId)) entitlementToken := pagination.NewEntitlementToken(filter.Token()) // nolint:gosec accept integer conversion - tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, role, int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion + tuples, nextEntitlmentToken, err := s.jimm.PermissionManager().ListObjectRelations(ctx, user, role, int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion if err != nil { return nil, err } @@ -215,13 +215,13 @@ func (s *rolesService) PatchRoleEntitlements(ctx context.Context, roleId string, return false, err } if toAdd != nil { - err := s.jimm.AddRelation(ctx, user, toAdd) + err := s.jimm.PermissionManager().AddRelation(ctx, user, toAdd) if err != nil { return false, err } } if toRemove != nil { - err := s.jimm.RemoveRelation(ctx, user, toRemove) + err := s.jimm.PermissionManager().RemoveRelation(ctx, user, toRemove) if err != nil { return false, err } diff --git a/internal/jimmhttp/rebac_admin/roles_test.go b/internal/jimmhttp/rebac_admin/roles_test.go index bdffc1a34..6dc239d8d 100644 --- a/internal/jimmhttp/rebac_admin/roles_test.go +++ b/internal/jimmhttp/rebac_admin/roles_test.go @@ -165,11 +165,14 @@ func TestGetRoleEntitlements(t *testing.T) { Relation: ofga.Relation("member"), Target: &ofga.Entity{Kind: "role", ID: "my-role"}, } + permissionManager := mocks.PermissionManager{ + ListObjectRelations_: func(ctx context.Context, user *openfga.User, object string, pageSize int32, ct pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { + return []openfga.Tuple{testTuple}, pagination.NewEntitlementToken(continuationToken), listRelationsErr + }, + } jimm := jimmtest.JIMM{ - RelationService: mocks.RelationService{ - ListObjectRelations_: func(ctx context.Context, user *openfga.User, object string, pageSize int32, ct pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { - return []openfga.Tuple{testTuple}, pagination.NewEntitlementToken(continuationToken), listRelationsErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} @@ -206,14 +209,17 @@ func TestGetRoleEntitlements(t *testing.T) { func TestPatchRoleEntitlements(t *testing.T) { c := qt.New(t) var patchTuplesErr error + permissionManager := mocks.PermissionManager{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + } jimm := jimmtest.JIMM{ - RelationService: mocks.RelationService{ - AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, - RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { - return patchTuplesErr - }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager }, } user := openfga.User{} diff --git a/internal/jujuapi/access_control.go b/internal/jujuapi/access_control.go index 90272701c..a6245758a 100644 --- a/internal/jujuapi/access_control.go +++ b/internal/jujuapi/access_control.go @@ -130,7 +130,7 @@ func (r *controllerRoot) ListGroups(ctx context.Context, req apiparams.ListGroup func (r *controllerRoot) AddRelation(ctx context.Context, req apiparams.AddRelationRequest) error { const op = errors.Op("jujuapi.AddRelation") - if err := r.jimm.AddRelation(ctx, r.user, req.Tuples); err != nil { + if err := r.jimm.PermissionManager().AddRelation(ctx, r.user, req.Tuples); err != nil { zapctx.Error(ctx, "failed to add relation", zaputil.Error(err)) return errors.E(op, err) } @@ -142,7 +142,7 @@ func (r *controllerRoot) AddRelation(ctx context.Context, req apiparams.AddRelat func (r *controllerRoot) RemoveRelation(ctx context.Context, req apiparams.RemoveRelationRequest) error { const op = errors.Op("jujuapi.RemoveRelation") - err := r.jimm.RemoveRelation(ctx, r.user, req.Tuples) + err := r.jimm.PermissionManager().RemoveRelation(ctx, r.user, req.Tuples) if err != nil { zapctx.Error(ctx, "failed to delete tuple(s)", zap.NamedError("remove-relation-error", err)) return errors.E(op, err) @@ -157,7 +157,7 @@ func (r *controllerRoot) CheckRelation(ctx context.Context, req apiparams.CheckR const op = errors.Op("jujuapi.CheckRelation") checkResp := apiparams.CheckRelationResponse{Allowed: false} - allowed, err := r.jimm.CheckRelation(ctx, r.user, req.Tuple, false) + allowed, err := r.jimm.PermissionManager().CheckRelation(ctx, r.user, req.Tuple, false) if err != nil { zapctx.Error(ctx, "failed to check relation", zap.NamedError("check-relation-error", err)) return checkResp, errors.E(op, err) @@ -171,19 +171,19 @@ func (r *controllerRoot) CheckRelation(ctx context.Context, req apiparams.CheckR func (r *controllerRoot) ListRelationshipTuples(ctx context.Context, req apiparams.ListRelationshipTuplesRequest) (apiparams.ListRelationshipTuplesResponse, error) { const op = errors.Op("jujuapi.ListRelationshipTuples") - responseTuples, ct, err := r.jimm.ListRelationshipTuples(ctx, r.user, req.Tuple, req.PageSize, req.ContinuationToken) + responseTuples, ct, err := r.jimm.PermissionManager().ListRelationshipTuples(ctx, r.user, req.Tuple, req.PageSize, req.ContinuationToken) if err != nil { return apiparams.ListRelationshipTuplesResponse{}, errors.E(op, err) } errors := []string{} tuples := make([]apiparams.RelationshipTuple, len(responseTuples)) for i, t := range responseTuples { - object, err := r.jimm.ToJAASTag(ctx, t.Object, req.ResolveUUIDs) + object, err := r.jimm.PermissionManager().ToJAASTag(ctx, t.Object, req.ResolveUUIDs) if err != nil { object = t.Object.String() errors = append(errors, "failed to parse object: "+err.Error()) } - target, err := r.jimm.ToJAASTag(ctx, t.Target, req.ResolveUUIDs) + target, err := r.jimm.PermissionManager().ToJAASTag(ctx, t.Target, req.ResolveUUIDs) if err != nil { target = t.Target.String() errors = append(errors, "failed to parse target: "+err.Error()) diff --git a/internal/jujuapi/applicationoffers.go b/internal/jujuapi/applicationoffers.go index f7e3dc724..dac4025da 100644 --- a/internal/jujuapi/applicationoffers.go +++ b/internal/jujuapi/applicationoffers.go @@ -175,12 +175,12 @@ func (r *controllerRoot) modifyOfferAccess(ctx context.Context, change jujuparam } switch change.Action { case jujuparams.GrantOfferAccess: - if err := r.jimm.GrantOfferAccess(ctx, r.user, change.OfferURL, ut, change.Access); err != nil { + if err := r.jimm.PermissionManager().GrantOfferAccess(ctx, r.user, change.OfferURL, ut, change.Access); err != nil { return errors.E(op, err) } return nil case jujuparams.RevokeOfferAccess: - if err := r.jimm.RevokeOfferAccess(ctx, r.user, change.OfferURL, ut, change.Access); err != nil { + if err := r.jimm.PermissionManager().RevokeOfferAccess(ctx, r.user, change.OfferURL, ut, change.Access); err != nil { return errors.E(op, err) } return nil diff --git a/internal/jujuapi/cloud.go b/internal/jujuapi/cloud.go index baf25e2a5..927e67c98 100644 --- a/internal/jujuapi/cloud.go +++ b/internal/jujuapi/cloud.go @@ -15,6 +15,7 @@ import ( "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jimm" + "github.com/canonical/jimm/v3/internal/jimm/permissions" "github.com/canonical/jimm/v3/internal/jujuapi/rpc" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" @@ -411,9 +412,9 @@ func (r *controllerRoot) modifyCloudAccess(ctx context.Context, change jujuparam var modifyf func(context.Context, *openfga.User, names.CloudTag, names.UserTag, string) error switch change.Action { case jujuparams.GrantCloudAccess: - modifyf = r.jimm.GrantCloudAccess + modifyf = r.jimm.PermissionManager().GrantCloudAccess case jujuparams.RevokeCloudAccess: - modifyf = r.jimm.RevokeCloudAccess + modifyf = r.jimm.PermissionManager().RevokeCloudAccess default: return errors.E(op, errors.CodeBadRequest, fmt.Sprintf("unsupported modify cloud action %q", change.Action)) } @@ -520,7 +521,7 @@ func (r *controllerRoot) ListCloudInfo(ctx context.Context, args jujuparams.List results = append(results, jujuparams.ListCloudInfoResult{ Result: &jujuparams.ListCloudInfo{ CloudDetails: c.ToJujuCloudDetails(), - Access: jimm.ToCloudAccessString(r.user.GetCloudAccess(ctx, c.ResourceTag())), + Access: permissions.ToCloudAccessString(r.user.GetCloudAccess(ctx, c.ResourceTag())), }, }) return nil diff --git a/internal/jujuapi/controller.go b/internal/jujuapi/controller.go index d53ed9ad5..f424b0e4d 100644 --- a/internal/jujuapi/controller.go +++ b/internal/jujuapi/controller.go @@ -250,7 +250,7 @@ func (r *controllerRoot) GetControllerAccess(ctx context.Context, args jujuparam results[i].Error = mapError(errors.E(op, err, errors.CodeBadRequest)) continue } - access, err := r.jimm.GetJimmControllerAccess(ctx, r.user, tag) + access, err := r.jimm.PermissionManager().GetJimmControllerAccess(ctx, r.user, tag) if err != nil { results[i].Error = mapError(errors.E(op, err)) continue diff --git a/internal/jujuapi/interface.go b/internal/jujuapi/interface.go index 0e260420b..c05e56aea 100644 --- a/internal/jujuapi/interface.go +++ b/internal/jujuapi/interface.go @@ -16,14 +16,11 @@ import ( "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/openfga" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/pubsub" - jimmnames "github.com/canonical/jimm/v3/pkg/names" ) // JIMM defines a comprehensive interface for all sort of operations with our application logic. type JIMM interface { - RelationService ControllerService ModelManager AddAuditLogEntry(ale *dbmodel.AuditLogEntry) @@ -46,15 +43,8 @@ type JIMM interface { GroupManager() jimm.GroupManager IdentityManager() jimm.IdentityManager LoginManager() jimm.LoginManager - GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, 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) - GrantAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error - GrantCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error - GrantModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error - GrantOfferAccess(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error - GrantServiceAccountAccess(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, tags []string) error + PermissionManager() jimm.PermissionManager + InitiateInternalMigration(ctx context.Context, user *openfga.User, modelNameOrUUID string, 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) @@ -67,12 +57,8 @@ type JIMM interface { RemoveCloudFromController(ctx context.Context, u *openfga.User, controllerName string, ct names.CloudTag) error RemoveController(ctx context.Context, user *openfga.User, controllerName string, force bool) error ResourceTag() names.ControllerTag - RevokeAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error - RevokeCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error RevokeCloudCredential(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error - RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error - RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) - ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) + UpdateCloud(ctx context.Context, u *openfga.User, ct names.CloudTag, cloud jujuparams.Cloud) error UpdateCloudCredential(ctx context.Context, u *openfga.User, args jimm.UpdateCloudCredentialArgs) ([]jujuparams.UpdateCredentialModelResult, error) } diff --git a/internal/jujuapi/jimm.go b/internal/jujuapi/jimm.go index 14bca66a7..5c0aa6829 100644 --- a/internal/jujuapi/jimm.go +++ b/internal/jujuapi/jimm.go @@ -368,7 +368,7 @@ func (r *controllerRoot) GrantAuditLogAccess(ctx context.Context, req apiparams. return errors.E(op, err, errors.CodeBadRequest) } - err = r.jimm.GrantAuditLogAccess(ctx, r.user, ut) + err = r.jimm.PermissionManager().GrantAuditLogAccess(ctx, r.user, ut) if err != nil { return errors.E(op, err) } @@ -386,7 +386,7 @@ func (r *controllerRoot) RevokeAuditLogAccess(ctx context.Context, req apiparams return errors.E(op, err, errors.CodeBadRequest) } - err = r.jimm.RevokeAuditLogAccess(ctx, r.user, ut) + err = r.jimm.PermissionManager().RevokeAuditLogAccess(ctx, r.user, ut) if err != nil { return errors.E(op, err) } diff --git a/internal/jujuapi/jimm_relation.go b/internal/jujuapi/jimm_relation.go index 2e54225d6..1104b7d99 100644 --- a/internal/jujuapi/jimm_relation.go +++ b/internal/jujuapi/jimm_relation.go @@ -1,20 +1,3 @@ // Copyright 2024 Canonical. package jujuapi - -import ( - "context" - - "github.com/canonical/jimm/v3/internal/common/pagination" - "github.com/canonical/jimm/v3/internal/openfga" - apiparams "github.com/canonical/jimm/v3/pkg/api/params" -) - -// RelationService defines an interface used to manage relations in the authorization model. -type RelationService interface { - AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error - RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error - CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) - ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) - ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) -} diff --git a/internal/jujuapi/modelmanager.go b/internal/jujuapi/modelmanager.go index f673f338a..9657280f7 100644 --- a/internal/jujuapi/modelmanager.go +++ b/internal/jujuapi/modelmanager.go @@ -261,9 +261,9 @@ func (r *controllerRoot) ModifyModelAccess(ctx context.Context, args jujuparams. } switch change.Action { case jujuparams.GrantModelAccess: - err = r.jimm.GrantModelAccess(ctx, r.user, mt, user, change.Access) + err = r.jimm.PermissionManager().GrantModelAccess(ctx, r.user, mt, user, change.Access) case jujuparams.RevokeModelAccess: - err = r.jimm.RevokeModelAccess(ctx, r.user, mt, user, change.Access) + err = r.jimm.PermissionManager().RevokeModelAccess(ctx, r.user, mt, user, change.Access) default: err = errors.E(op, errors.CodeBadRequest, fmt.Sprintf("invalid action %q", change.Action)) } diff --git a/internal/jujuapi/service_account.go b/internal/jujuapi/service_account.go index bfa8262aa..b89b83c11 100644 --- a/internal/jujuapi/service_account.go +++ b/internal/jujuapi/service_account.go @@ -144,5 +144,5 @@ func (r *controllerRoot) GrantServiceAccountAccess(ctx context.Context, req apip } svcAccTag := jimmnames.NewServiceAccountTag(clientIdWithDomain) - return r.jimm.GrantServiceAccountAccess(ctx, r.user, svcAccTag, req.Entities) + return r.jimm.PermissionManager().GrantServiceAccountAccess(ctx, r.user, svcAccTag, req.Entities) } diff --git a/internal/jujuapi/service_account_test.go b/internal/jujuapi/service_account_test.go index b145c1280..b6fe1ed51 100644 --- a/internal/jujuapi/service_account_test.go +++ b/internal/jujuapi/service_account_test.go @@ -733,11 +733,16 @@ func TestGrantServiceAccountAccess(t *testing.T) { return nil, nil }, } - jimm := &jimmtest.JIMM{ + permissionManager := mocks.PermissionManager{ GrantServiceAccountAccess_: test.grantServiceAccountAccess, + } + jimm := &jimmtest.JIMM{ LoginManager_: func() jimm.LoginManager { return &loginManager }, + PermissionManager_: func() jimm.PermissionManager { + return &permissionManager + }, } var u dbmodel.Identity u.SetTag(names.NewUserTag(test.username)) diff --git a/internal/jujuapi/websocket.go b/internal/jujuapi/websocket.go index 48c37f3e1..d807f3bb4 100644 --- a/internal/jujuapi/websocket.go +++ b/internal/jujuapi/websocket.go @@ -173,7 +173,7 @@ func modelInfoFromPath(path string) (uuid string, finalPath string, err error) { // We act as a proxier, handling auth on requests before forwarding the // requests to the appropriate Juju controller. func (s apiProxier) ServeWS(ctx context.Context, clientConn *websocket.Conn) { - jwtGenerator := jujuauth.New(s.jimm.Database, s.jimm, s.jimm.JWTService) + jwtGenerator := s.jimm.NewJujuAuthenticator() connectionFunc := controllerConnectionFunc(s, &jwtGenerator) zapctx.Debug(ctx, "Starting proxier") auditLogger := s.jimm.AddAuditLogEntry diff --git a/internal/testutils/jimmtest/env.go b/internal/testutils/jimmtest/env.go index 4556fe402..f68f0f97c 100644 --- a/internal/testutils/jimmtest/env.go +++ b/internal/testutils/jimmtest/env.go @@ -197,6 +197,29 @@ func (ctl Controller) addControllerRelations(c *qt.C, client *openfga.OFGAClient c.Assert(err, qt.IsNil) } +// addAppOfferRelations adds permissions the application offer should have and adds permissions for users to the offer. +func (ao ApplicationOffer) addAppOfferRelations(c *qt.C, db *db.Database, client *openfga.OFGAClient) { + for _, u := range ao.Users { + dbUser := ao.env.User(u.User).DBObject(c, db) + var relation openfga.Relation + switch u.Access { + case "admin": + relation = ofganames.AdministratorRelation + case "consume": + relation = ofganames.ConsumerRelation + case "read": + relation = ofganames.ReaderRelation + default: + c.Fatalf("unknown application offer access: %s %s", dbUser.Name, u.Access) + } + user := openfga.NewUser(&dbUser, client) + err := user.SetApplicationOfferAccess(context.Background(), ao.dbo.ResourceTag(), relation) + c.Assert(err, qt.IsNil) + } + err := client.AddModelApplicationOffer(context.Background(), ao.dbo.Model.ResourceTag(), ao.dbo.ResourceTag()) + c.Assert(err, qt.IsNil) +} + func (e *Environment) addJIMMRelations(c *qt.C, jimmTag names.ControllerTag, db *db.Database, client *openfga.OFGAClient) { for _, user := range e.Users { user.addUserRelations(c, jimmTag, db, client) @@ -214,6 +237,9 @@ func (e *Environment) addJIMMRelations(c *qt.C, jimmTag names.ControllerTag, db for _, ctl := range e.Controllers { ctl.addControllerRelations(c, client) } + for _, appOffer := range e.ApplicationOffers { + appOffer.addAppOfferRelations(c, db, client) + } } func (e *Environment) PopulateDBAndPermissions(c *qt.C, jimmTag names.ControllerTag, db *db.Database, client *openfga.OFGAClient) { @@ -255,11 +281,12 @@ func (e *Environment) PopulateDB(c Tester, db *db.Database) { // ApplicationOffer represents Juju application offers. type ApplicationOffer struct { - ModelName string `json:"model-name"` - ModelOwner string `json:"model-owner"` - Name string `json:"name"` - UUID string `json:"uuid"` - URL string `json:"url"` + ModelName string `json:"model-name"` + ModelOwner string `json:"model-owner"` + Name string `json:"name"` + UUID string `json:"uuid"` + URL string `json:"url"` + Users []UserAccess `json:"users"` env *Environment dbo dbmodel.ApplicationOffer diff --git a/internal/testutils/jimmtest/jimm_mock.go b/internal/testutils/jimmtest/jimm_mock.go index 94cd4e376..3e5cdee90 100644 --- a/internal/testutils/jimmtest/jimm_mock.go +++ b/internal/testutils/jimmtest/jimm_mock.go @@ -19,10 +19,8 @@ import ( "github.com/canonical/jimm/v3/internal/jimm" jimmcreds "github.com/canonical/jimm/v3/internal/jimm/credentials" "github.com/canonical/jimm/v3/internal/openfga" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/pubsub" "github.com/canonical/jimm/v3/internal/testutils/jimmtest/mocks" - jimmnames "github.com/canonical/jimm/v3/pkg/names" ) // JIMM is a default implementation of the jujuapi.JIMM interface. Every method @@ -30,15 +28,19 @@ import ( // will delegate to the requested funcion or if the funcion is nil return // a NotImplemented error. type JIMM struct { - mocks.RelationService mocks.ControllerService mocks.ModelManager + + GroupManager_ func() jimm.GroupManager + IdentityManager_ func() jimm.IdentityManager + LoginManager_ func() jimm.LoginManager + RoleManager_ func() jimm.RoleManager + PermissionManager_ func() jimm.PermissionManager + AddAuditLogEntry_ func(ale *dbmodel.AuditLogEntry) AddCloudToController_ func(ctx context.Context, user *openfga.User, controllerName string, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddHostedCloud_ func(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddServiceAccount_ func(ctx context.Context, u *openfga.User, clientId string) error - Authenticate_ func(ctx context.Context, req *jujuparams.LoginRequest) (*openfga.User, error) - CheckPermission_ func(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) CopyServiceAccountCredential_ func(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) DestroyOffer_ func(ctx context.Context, user *openfga.User, offerURL string, force bool) error FindApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) @@ -52,18 +54,6 @@ type JIMM struct { GetCloudCredential_ func(ctx context.Context, user *openfga.User, tag names.CloudCredentialTag) (*dbmodel.CloudCredential, error) GetCloudCredentialAttributes_ func(ctx context.Context, u *openfga.User, cred *dbmodel.CloudCredential, hidden bool) (attrs map[string]string, redacted []string, err error) GetCredentialStore_ func() jimmcreds.CredentialStore - GetJimmControllerAccess_ func(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) - GetUserCloudAccess_ func(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) - GetUserControllerAccess_ func(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) - GetUserModelAccess_ func(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) - GrantAuditLogAccess_ func(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error - GrantCloudAccess_ func(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error - GrantModelAccess_ func(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error - GrantOfferAccess_ func(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error - GrantServiceAccountAccess_ func(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error - GroupManager_ func() jimm.GroupManager - IdentityManager_ func() jimm.IdentityManager - LoginManager_ func() jimm.LoginManager InitiateInternalMigration_ func(ctx context.Context, user *openfga.User, modelNameOrUUID string, targetController string) (jujuparams.InitiateMigrationResult, error) InitiateMigration_ func(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) @@ -74,13 +64,7 @@ type JIMM struct { RemoveCloud_ func(ctx context.Context, u *openfga.User, ct names.CloudTag) error RemoveCloudFromController_ func(ctx context.Context, u *openfga.User, controllerName string, ct names.CloudTag) error ResourceTag_ func() names.ControllerTag - RevokeAuditLogAccess_ func(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error - RevokeCloudAccess_ func(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error RevokeCloudCredential_ func(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error - RevokeModelAccess_ func(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error - RevokeOfferAccess_ func(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) - RoleManager_ func() jimm.RoleManager - ToJAASTag_ func(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) UpdateApplicationOffer_ func(ctx context.Context, controller *dbmodel.Controller, offerUUID string, removed bool) error UpdateCloud_ func(ctx context.Context, u *openfga.User, ct names.CloudTag, cloud jujuparams.Cloud) error UpdateCloudCredential_ func(ctx context.Context, u *openfga.User, args jimm.UpdateCloudCredentialArgs) ([]jujuparams.UpdateCredentialModelResult, error) @@ -120,19 +104,6 @@ func (j *JIMM) CopyServiceAccountCredential(ctx context.Context, u *openfga.User return j.CopyServiceAccountCredential_(ctx, u, svcAcc, cloudCredentialTag) } -func (j *JIMM) Authenticate(ctx context.Context, req *jujuparams.LoginRequest) (*openfga.User, error) { - if j.Authenticate_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.Authenticate_(ctx, req) -} - -func (j *JIMM) CheckPermission(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) { - if j.CheckPermission_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.CheckPermission_(ctx, user, cachedPerms, desiredPerms) -} func (j *JIMM) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error { if j.DestroyOffer_ == nil { return errors.E(errors.CodeNotImplemented) @@ -237,60 +208,11 @@ func (j *JIMM) LoginManager() jimm.LoginManager { return j.LoginManager_() } -func (j *JIMM) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) { - if j.GetJimmControllerAccess_ == nil { - return "", errors.E(errors.CodeNotImplemented) - } - return j.GetJimmControllerAccess_(ctx, user, tag) -} -func (j *JIMM) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) { - if j.GetUserCloudAccess_ == nil { - return "", errors.E(errors.CodeNotImplemented) - } - return j.GetUserCloudAccess_(ctx, user, cloud) -} -func (j *JIMM) GetUserControllerAccess(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) { - if j.GetUserControllerAccess_ == nil { - return "", errors.E(errors.CodeNotImplemented) - } - return j.GetUserControllerAccess_(ctx, user, controller) -} -func (j *JIMM) GetUserModelAccess(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) { - if j.GetUserModelAccess_ == nil { - return "", errors.E(errors.CodeNotImplemented) - } - return j.GetUserModelAccess_(ctx, user, model) -} -func (j *JIMM) GrantAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { - if j.GrantAuditLogAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.GrantAuditLogAccess_(ctx, user, targetUserTag) -} -func (j *JIMM) GrantCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { - if j.GrantCloudAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.GrantCloudAccess_(ctx, user, ct, ut, access) -} -func (j *JIMM) GrantModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { - if j.GrantModelAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.GrantModelAccess_(ctx, user, mt, ut, access) -} -func (j *JIMM) GrantOfferAccess(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error { - if j.GrantOfferAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.GrantOfferAccess_(ctx, u, offerURL, ut, access) -} - -func (j *JIMM) GrantServiceAccountAccess(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error { - if j.GrantServiceAccountAccess_ == nil { - return errors.E(errors.CodeNotImplemented) +func (j *JIMM) PermissionManager() jimm.PermissionManager { + if j.PermissionManager_ == nil { + return nil } - return j.GrantServiceAccountAccess_(ctx, u, svcAccTag, entities) + return j.PermissionManager_() } func (j *JIMM) InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) { @@ -353,43 +275,12 @@ func (j *JIMM) ResourceTag() names.ControllerTag { } return j.ResourceTag_() } -func (j *JIMM) RevokeAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { - if j.RevokeAuditLogAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.RevokeAuditLogAccess_(ctx, user, targetUserTag) -} -func (j *JIMM) RevokeCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { - if j.RevokeCloudAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.RevokeCloudAccess_(ctx, user, ct, ut, access) -} func (j *JIMM) RevokeCloudCredential(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error { if j.RevokeCloudCredential_ == nil { return errors.E(errors.CodeNotImplemented) } return j.RevokeCloudCredential_(ctx, user, tag, force) } -func (j *JIMM) RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { - if j.RevokeModelAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.RevokeModelAccess_(ctx, user, mt, ut, access) -} -func (j *JIMM) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) { - if j.RevokeOfferAccess_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.RevokeOfferAccess_(ctx, user, offerURL, ut, access) -} -func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) { - if j.ToJAASTag_ == nil { - return "", errors.E(errors.CodeNotImplemented) - } - return j.ToJAASTag_(ctx, tag, resolveUUIDs) -} - func (j *JIMM) UpdateApplicationOffer(ctx context.Context, controller *dbmodel.Controller, offerUUID string, removed bool) error { if j.UpdateApplicationOffer_ == nil { return errors.E(errors.CodeNotImplemented) diff --git a/internal/testutils/jimmtest/mocks/jimm_relation_mock.go b/internal/testutils/jimmtest/mocks/jimm_relation_mock.go index 06fb1652b..319ed45a2 100644 --- a/internal/testutils/jimmtest/mocks/jimm_relation_mock.go +++ b/internal/testutils/jimmtest/mocks/jimm_relation_mock.go @@ -5,52 +5,182 @@ package mocks import ( "context" + jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/names/v5" + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" apiparams "github.com/canonical/jimm/v3/pkg/api/params" + jimmnames "github.com/canonical/jimm/v3/pkg/names" ) -// RelationService is an implementation of the jujuapi.RelationService interface. -type RelationService struct { +// PermissionManager is an implementation of the jujuapi.PermissionManager interface. +type PermissionManager struct { AddRelation_ func(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error RemoveRelation_ func(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error CheckRelation_ func(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) ListRelationshipTuples_ func(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) ListObjectRelations_ func(ctx context.Context, user *openfga.User, object string, pageSize int32, continuationToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) + + GetJimmControllerAccess_ func(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) + GetUserCloudAccess_ func(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) + GetUserControllerAccess_ func(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) + GetUserModelAccess_ func(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) + GrantAuditLogAccess_ func(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error + GrantCloudAccess_ func(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error + GrantModelAccess_ func(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error + GrantOfferAccess_ func(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error + GrantServiceAccountAccess_ func(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error + + RevokeAuditLogAccess_ func(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error + RevokeCloudAccess_ func(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error + RevokeCloudCredential_ func(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error + RevokeModelAccess_ func(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error + RevokeOfferAccess_ func(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) + + OpenFGACleanup_ func(ctx context.Context) error + ToJAASTag_ func(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) } -func (j *RelationService) AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { +func (j *PermissionManager) AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { if j.AddRelation_ == nil { return errors.E(errors.CodeNotImplemented) } return j.AddRelation_(ctx, user, tuples) } -func (j *RelationService) RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { +func (j *PermissionManager) RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { if j.RemoveRelation_ == nil { return errors.E(errors.CodeNotImplemented) } return j.RemoveRelation_(ctx, user, tuples) } -func (j *RelationService) CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) { +func (j *PermissionManager) CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) { if j.CheckRelation_ == nil { return false, errors.E(errors.CodeNotImplemented) } return j.CheckRelation_(ctx, user, tuple, trace) } -func (j *RelationService) ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { +func (j *PermissionManager) ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { if j.ListRelationshipTuples_ == nil { return []openfga.Tuple{}, "", errors.E(errors.CodeNotImplemented) } return j.ListRelationshipTuples_(ctx, user, tuple, pageSize, continuationToken) } -func (j *RelationService) ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { +func (j *PermissionManager) ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { if j.ListObjectRelations_ == nil { return []openfga.Tuple{}, pagination.EntitlementToken{}, errors.E(errors.CodeNotImplemented) } return j.ListObjectRelations_(ctx, user, object, pageSize, entitlementToken) } + +func (j *PermissionManager) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) { + if j.GetJimmControllerAccess_ == nil { + return "", errors.E(errors.CodeNotImplemented) + } + return j.GetJimmControllerAccess_(ctx, user, tag) +} + +func (j *PermissionManager) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) { + if j.GetUserCloudAccess_ == nil { + return "", errors.E(errors.CodeNotImplemented) + } + return j.GetUserCloudAccess_(ctx, user, cloud) +} + +func (j *PermissionManager) GetUserControllerAccess(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) { + if j.GetUserControllerAccess_ == nil { + return "", errors.E(errors.CodeNotImplemented) + } + return j.GetUserControllerAccess_(ctx, user, controller) +} + +func (j *PermissionManager) GetUserModelAccess(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) { + if j.GetUserModelAccess_ == nil { + return "", errors.E(errors.CodeNotImplemented) + } + return j.GetUserModelAccess_(ctx, user, model) +} + +func (j *PermissionManager) GrantAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { + if j.GrantAuditLogAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.GrantAuditLogAccess_(ctx, user, targetUserTag) +} + +func (j *PermissionManager) GrantCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { + if j.GrantCloudAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.GrantCloudAccess_(ctx, user, ct, ut, access) +} + +func (j *PermissionManager) GrantModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { + if j.GrantModelAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.GrantModelAccess_(ctx, user, mt, ut, access) +} + +func (j *PermissionManager) GrantOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error { + if j.GrantOfferAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.GrantOfferAccess_(ctx, user, offerURL, ut, access) +} + +func (j *PermissionManager) GrantServiceAccountAccess(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error { + if j.GrantServiceAccountAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.GrantServiceAccountAccess_(ctx, u, svcAccTag, entities) +} + +func (j *PermissionManager) RevokeAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error { + if j.RevokeAuditLogAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RevokeAuditLogAccess_(ctx, user, targetUserTag) +} + +func (j *PermissionManager) RevokeCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error { + if j.RevokeCloudAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RevokeCloudAccess_(ctx, user, ct, ut, access) +} + +func (j *PermissionManager) RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error { + if j.RevokeModelAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RevokeModelAccess_(ctx, user, mt, ut, access) +} + +func (j *PermissionManager) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) { + if j.RevokeOfferAccess_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RevokeOfferAccess_(ctx, user, offerURL, ut, access) +} + +func (j *PermissionManager) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) { + if j.ToJAASTag_ == nil { + return "", errors.E(errors.CodeNotImplemented) + } + return j.ToJAASTag_(ctx, tag, resolveUUIDs) +} + +func (j *PermissionManager) OpenFGACleanup(ctx context.Context) error { + if j.OpenFGACleanup_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.OpenFGACleanup_(ctx) +}