From 6031feb396d4a716717570cfae43961b534ada04 Mon Sep 17 00:00:00 2001 From: Kian Parvin <46668016+kian99@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:00:22 +0200 Subject: [PATCH] feat: add rebac-admin handlers for roles (#1469) Handlers are implemented identically as those for groups. --- internal/jimmhttp/rebac_admin/backend.go | 1 + internal/jimmhttp/rebac_admin/export_test.go | 2 + internal/jimmhttp/rebac_admin/identities.go | 107 ++++++- .../identities_integration_test.go | 98 ++++++- .../jimmhttp/rebac_admin/identities_test.go | 102 +++++++ internal/jimmhttp/rebac_admin/roles.go | 230 +++++++++++++++ .../rebac_admin/roles_integration_test.go | 232 ++++++++++++++++ internal/jimmhttp/rebac_admin/roles_test.go | 262 ++++++++++++++++++ internal/openfga/names/common.go | 6 + internal/testutils/jimmtest/suite.go | 7 + 10 files changed, 1035 insertions(+), 12 deletions(-) create mode 100644 internal/jimmhttp/rebac_admin/roles.go create mode 100644 internal/jimmhttp/rebac_admin/roles_integration_test.go create mode 100644 internal/jimmhttp/rebac_admin/roles_test.go diff --git a/internal/jimmhttp/rebac_admin/backend.go b/internal/jimmhttp/rebac_admin/backend.go index 3e03de032..42e933522 100644 --- a/internal/jimmhttp/rebac_admin/backend.go +++ b/internal/jimmhttp/rebac_admin/backend.go @@ -23,6 +23,7 @@ func SetupBackend(ctx context.Context, jimm jujuapi.JIMM) (*rebac_handlers.ReBAC Identities: newidentitiesService(jimm), Resources: newResourcesService(jimm), Capabilities: newCapabilitiesService(), + Roles: newRoleService(jimm), }) if err != nil { zapctx.Error(ctx, "failed to create rebac admin backend", zap.Error(err)) diff --git a/internal/jimmhttp/rebac_admin/export_test.go b/internal/jimmhttp/rebac_admin/export_test.go index 1eb86854e..54ff4c116 100644 --- a/internal/jimmhttp/rebac_admin/export_test.go +++ b/internal/jimmhttp/rebac_admin/export_test.go @@ -3,6 +3,7 @@ package rebac_admin var ( NewGroupService = newGroupService + NewRoleService = newRoleService NewidentitiesService = newidentitiesService NewResourcesService = newResourcesService NewEntitlementService = newEntitlementService @@ -11,3 +12,4 @@ var ( ) type GroupsService = groupsService +type RolesService = rolesService diff --git a/internal/jimmhttp/rebac_admin/identities.go b/internal/jimmhttp/rebac_admin/identities.go index b2684d097..53e73991f 100644 --- a/internal/jimmhttp/rebac_admin/identities.go +++ b/internal/jimmhttp/rebac_admin/identities.go @@ -93,19 +93,105 @@ func (s *identitiesService) UpdateIdentity(ctx context.Context, identity *resour return nil, v1.NewNotImplementedError("update identity not implemented") } -// // DeleteIdentity deletes an Identity. +// DeleteIdentity deletes an Identity. func (s *identitiesService) DeleteIdentity(ctx context.Context, identityId string) (bool, error) { return false, v1.NewNotImplementedError("delete identity not implemented") } -// // GetIdentityRoles returns a page of Roles for identity `identityId`. +// GetIdentityRoles returns a page of identities in a Role identified by `roleId`. func (s *identitiesService) GetIdentityRoles(ctx context.Context, identityId string, params *resources.GetIdentitiesItemRolesParams) (*resources.PaginatedResponse[resources.Role], error) { - return nil, v1.NewNotImplementedError("get identity roles not implemented") + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + objUser, err := s.jimm.FetchIdentity(ctx, identityId) + if err != nil { + 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{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.AssigneeRelation.String(), + TargetObject: openfga.RoleType.String(), + }, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion + if err != nil { + return nil, err + } + + roles := make([]resources.Role, 0, len(tuples)) + for _, t := range tuples { + dbRole, err := s.jimm.GetRoleManager().GetRoleByUUID(ctx, user, t.Target.ID) + if err != nil { + // Handle the case where the role was removed from the DB but a lingering OpenFGA tuple still exists. + // Don't return an error as that would prevent a user from viewing their groups, instead drop the role from the result. + if errors.ErrorCode(err) == errors.CodeNotFound { + continue + } + return nil, err + } + roles = append(roles, resources.Role{ + Id: &t.Target.ID, + Name: dbRole.Name, + }) + } + + originalToken := filter.Token() + res := resources.PaginatedResponse[resources.Role]{ + Data: roles, + Meta: resources.ResponseMeta{ + Size: len(roles), + PageToken: &originalToken, + }, + } + if cNextToken != "" { + res.Next.PageToken = &cNextToken + } + return &res, nil } -// // PatchIdentityRoles performs addition or removal of a Role to/from an Identity. +// PatchRoleIdentities performs addition or removal of identities to/from a Role identified by `roleId`. func (s *identitiesService) PatchIdentityRoles(ctx context.Context, identityId string, rolePatches []resources.IdentityRolesPatchItem) (bool, error) { - return false, v1.NewNotImplementedError("get identity roles not implemented") + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + + objUser, err := s.jimm.FetchIdentity(ctx, identityId) + if err != nil { + return false, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) + } + additions := make([]apiparams.RelationshipTuple, 0) + deletions := make([]apiparams.RelationshipTuple, 0) + for _, p := range rolePatches { + if !jimmnames.IsValidRoleId(p.Role) { + return false, v1.NewValidationError(fmt.Sprintf("ID %s is not a valid role ID", p.Role)) + } + t := apiparams.RelationshipTuple{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.AssigneeRelation.String(), + TargetObject: jimmnames.NewRoleTag(p.Role).String(), + } + if p.Op == resources.IdentityRolesPatchItemOpAdd { + additions = append(additions, t) + } else if p.Op == "remove" { + deletions = append(deletions, t) + } + } + if len(additions) > 0 { + err = s.jimm.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) + if err != nil { + zapctx.Error(context.Background(), "cannot remove relations", zap.Error(err)) + return false, v1.NewUnknownError(err.Error()) + } + } + return true, nil } // GetIdentityGroups returns a page of Groups for identity `identityId`. @@ -146,16 +232,17 @@ func (s *identitiesService) GetIdentityGroups(ctx context.Context, identityId st } originalToken := filter.Token() - return &resources.PaginatedResponse[resources.Group]{ + res := resources.PaginatedResponse[resources.Group]{ Data: groups, Meta: resources.ResponseMeta{ Size: len(groups), PageToken: &originalToken, }, - Next: resources.Next{ - PageToken: &cNextToken, - }, - }, nil + } + if cNextToken != "" { + res.Next.PageToken = &cNextToken + } + return &res, nil } // PatchIdentityGroups performs addition or removal of a Group to/from an Identity. diff --git a/internal/jimmhttp/rebac_admin/identities_integration_test.go b/internal/jimmhttp/rebac_admin/identities_integration_test.go index d57ddbce2..04343a52d 100644 --- a/internal/jimmhttp/rebac_admin/identities_integration_test.go +++ b/internal/jimmhttp/rebac_admin/identities_integration_test.go @@ -127,21 +127,24 @@ func (s *identitiesSuite) TestIdentityGetGroups(c *gc.C) { // test list identity's groups with token pagination size := 3 token := "" + totalGroups := 0 for i := 0; ; i += size { groups, err := identitySvc.GetIdentityGroups(ctx, username, &resources.GetIdentitiesItemGroupsParams{ Size: &size, NextToken: &token, }) c.Assert(err, gc.IsNil) - token = *groups.Next.PageToken for j := 0; j < len(groups.Data); j++ { + totalGroups++ c.Assert(groups.Data[j].Name, gc.Matches, `group-test\d+`) c.Assert(groupTags[j].Id(), gc.Matches, `\w*-\w*-\w*-\w*-\w*`) } - if *groups.Next.PageToken == "" { + if groups.Next.PageToken == nil || *groups.Next.PageToken == "" { break } + token = *groups.Next.PageToken } + c.Assert(totalGroups, gc.Equals, groupsSize) } // TestGetIdentityGroupsWithDeletedDbGroup tests the behaviour @@ -183,6 +186,97 @@ func (s *identitiesSuite) TestGetIdentityGroupsWithDeletedDbGroup(c *gc.C) { c.Assert(groups.Data, gc.HasLen, 1) } +func (s *identitiesSuite) TestIdentityPatchRoles(c *gc.C) { + // initialization + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + identitySvc := rebac_admin.NewidentitiesService(s.JIMM) + roleName := "role-test1" + username := s.AdminUser.Name + role := s.AddRole(c, roleName) + + // test add identity role + changed, err := identitySvc.PatchIdentityRoles(ctx, username, []resources.IdentityRolesPatchItem{{ + Role: role.UUID, + Op: resources.IdentityRolesPatchItemOpAdd, + }}) + c.Assert(err, gc.IsNil) + c.Assert(changed, gc.Equals, true) + + // test user added to roles + objUser, err := s.JIMM.FetchIdentity(ctx, username) + c.Assert(err, gc.IsNil) + tuples, _, err := s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.AssigneeRelation.String(), + TargetObject: role.ResourceTag().String(), + }, 10, "") + c.Assert(err, gc.IsNil) + c.Assert(len(tuples), gc.Equals, 1) + c.Assert(role.UUID, gc.Equals, tuples[0].Target.ID) + + // test user remove from role + changed, err = identitySvc.PatchIdentityRoles(ctx, username, []resources.IdentityRolesPatchItem{{ + Role: role.UUID, + Op: resources.IdentityRolesPatchItemOpRemove, + }}) + c.Assert(err, gc.IsNil) + c.Assert(changed, gc.Equals, true) + tuples, _, err = s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.AssigneeRelation.String(), + TargetObject: role.ResourceTag().String(), + }, 10, "") + c.Assert(err, gc.IsNil) + c.Assert(len(tuples), gc.Equals, 0) +} + +func (s *identitiesSuite) TestIdentityGetRoles(c *gc.C) { + // initialization + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + identitySvc := rebac_admin.NewidentitiesService(s.JIMM) + username := s.AdminUser.Name + rolesSize := 10 + rolesToAdd := make([]resources.IdentityRolesPatchItem, rolesSize) + roleTags := make([]jimmnames.RoleTag, rolesSize) + for i := range rolesSize { + roleName := fmt.Sprintf("role-test%d", i) + role := s.AddRole(c, roleName) + roleTags[i] = role.ResourceTag() + rolesToAdd[i] = resources.IdentityRolesPatchItem{ + Role: role.UUID, + Op: resources.IdentityRolesPatchItemOpAdd, + } + + } + changed, err := identitySvc.PatchIdentityRoles(ctx, username, rolesToAdd) + c.Assert(err, gc.IsNil) + c.Assert(changed, gc.Equals, true) + + // test list identity's roles with token pagination + size := 3 + token := "" + totalRoles := 0 + for i := 0; ; i += size { + roles, err := identitySvc.GetIdentityRoles(ctx, username, &resources.GetIdentitiesItemRolesParams{ + Size: &size, + NextToken: &token, + }) + c.Assert(err, gc.IsNil) + for j := 0; j < len(roles.Data); j++ { + totalRoles++ + c.Assert(roles.Data[j].Name, gc.Matches, `role-test\d+`) + c.Assert(roleTags[j].Id(), gc.Matches, `\w*-\w*-\w*-\w*-\w*`) + } + if roles.Next.PageToken == nil || *roles.Next.PageToken == "" { + break + } + token = *roles.Next.PageToken + } + c.Assert(totalRoles, gc.Equals, rolesSize) +} + // TestIdentityEntitlements tests the listing of entitlements for a specific identityId. // Setup: add controllers, models to a user and add the user to a group. func (s *identitiesSuite) TestIdentityEntitlements(c *gc.C) { diff --git a/internal/jimmhttp/rebac_admin/identities_test.go b/internal/jimmhttp/rebac_admin/identities_test.go index 0b91f15d1..b0716cc02 100644 --- a/internal/jimmhttp/rebac_admin/identities_test.go +++ b/internal/jimmhttp/rebac_admin/identities_test.go @@ -17,8 +17,10 @@ import ( "github.com/canonical/jimm/v3/internal/common/utils" "github.com/canonical/jimm/v3/internal/dbmodel" jimmm_errors "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/jimmhttp/rebac_admin" "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/testutils/jimmtest" "github.com/canonical/jimm/v3/internal/testutils/jimmtest/mocks" "github.com/canonical/jimm/v3/pkg/api/params" @@ -238,3 +240,103 @@ func TestPatchIdentityGroups(t *testing.T) { _, err = idSvc.PatchIdentityGroups(ctx, "bob@canonical.com", invalidGroupName) c.Assert(err, qt.ErrorMatches, "Bad Request: ID test-group1 is not a valid group ID") } + +func TestGetIdentityRoles(t *testing.T) { + c := qt.New(t) + var listTuplesErr error + testTuple := openfga.Tuple{ + Object: &ofga.Entity{Kind: "user", ID: "foo"}, + Relation: ofganames.AssigneeRelation, + Target: &ofga.Entity{Kind: "role", ID: "my-role-id"}, + } + roleManager := mocks.RoleManager{ + GetRoleByUUID_: func(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.RoleEntry, error) { + return &dbmodel.RoleEntry{Name: "fake-role-name"}, nil + }, + } + jimm := jimmtest.JIMM{ + FetchIdentity_: func(ctx context.Context, username string) (*openfga.User, error) { + if username == "bob@canonical.com" { + return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil + } + return nil, dbmodel.IdentityCreationError + }, + 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 + }, + }, + GetRoleManager_: func() jimm.RoleManager { + return roleManager + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + idSvc := rebac_admin.NewidentitiesService(&jimm) + + _, err := idSvc.GetIdentityRoles(ctx, "bob-not-found@canonical.com", &resources.GetIdentitiesItemRolesParams{}) + c.Assert(err, qt.ErrorMatches, ".*not found") + username := "bob@canonical.com" + + res, err := idSvc.GetIdentityRoles(ctx, username, &resources.GetIdentitiesItemRolesParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Data, qt.HasLen, 1) + c.Assert(*res.Data[0].Id, qt.Equals, "my-role-id") + c.Assert(res.Data[0].Name, qt.Equals, "fake-role-name") + c.Assert(*res.Next.PageToken, qt.Equals, "continuation-token") + + listTuplesErr = errors.New("foo") + _, err = idSvc.GetIdentityRoles(ctx, username, &resources.GetIdentitiesItemRolesParams{}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestPatchIdentityRoles(t *testing.T) { + c := qt.New(t) + var patchTuplesErr error + jimm := jimmtest.JIMM{ + FetchIdentity_: func(ctx context.Context, username string) (*openfga.User, error) { + if username == "bob@canonical.com" { + return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil + } + return nil, dbmodel.IdentityCreationError + }, + 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 + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + idSvc := rebac_admin.NewidentitiesService(&jimm) + + _, err := idSvc.PatchIdentityRoles(ctx, "bob-not-found@canonical.com", nil) + c.Assert(err, qt.ErrorMatches, ".* not found") + + username := "bob@canonical.com" + role1ID := uuid.New() + role2ID := uuid.New() + operations := []resources.IdentityRolesPatchItem{ + {Role: role1ID.String(), Op: resources.IdentityRolesPatchItemOpAdd}, + {Role: role2ID.String(), Op: resources.IdentityRolesPatchItemOpRemove}, + } + res, err := idSvc.PatchIdentityRoles(ctx, username, operations) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsTrue) + + patchTuplesErr = errors.New("foo") + _, err = idSvc.PatchIdentityRoles(ctx, username, operations) + c.Assert(err, qt.ErrorMatches, ".*foo") + + invalidRoleName := []resources.IdentityRolesPatchItem{ + {Role: "test-role1", Op: resources.IdentityRolesPatchItemOpAdd}, + } + _, err = idSvc.PatchIdentityRoles(ctx, "bob@canonical.com", invalidRoleName) + c.Assert(err, qt.ErrorMatches, "Bad Request: ID test-role1 is not a valid role ID") +} diff --git a/internal/jimmhttp/rebac_admin/roles.go b/internal/jimmhttp/rebac_admin/roles.go new file mode 100644 index 000000000..34303c53b --- /dev/null +++ b/internal/jimmhttp/rebac_admin/roles.go @@ -0,0 +1,230 @@ +// Copyright 2024 Canonical. + +package rebac_admin + +import ( + "context" + + v1 "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimmhttp/rebac_admin/utils" + "github.com/canonical/jimm/v3/internal/jujuapi" + 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" +) + +// rolesService implements the `RolesService` interface. +type rolesService struct { + jimm jujuapi.JIMM +} + +func newRoleService(jimm jujuapi.JIMM) *rolesService { + return &rolesService{ + jimm, + } +} + +// ListRoles returns a page of Role objects of at least `size` elements if available. +func (s *rolesService) ListRoles(ctx context.Context, params *resources.GetRolesParams) (*resources.PaginatedResponse[resources.Role], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + count, err := s.jimm.GetRoleManager().CountRoles(ctx, user) + if err != nil { + return nil, err + } + page, nextPage, pagination := pagination.CreatePagination(params.Size, params.Page, count) + match := "" + if params.Filter != nil && *params.Filter != "" { + match = *params.Filter + } + roles, err := s.jimm.GetRoleManager().ListRoles(ctx, user, pagination, match) + if err != nil { + return nil, err + } + + data := make([]resources.Role, 0, len(roles)) + for _, role := range roles { + data = append(data, resources.Role{Id: &role.UUID, Name: role.Name}) + } + resp := resources.PaginatedResponse[resources.Role]{ + Data: data, + Meta: resources.ResponseMeta{ + Page: &page, + Size: len(roles), + Total: &count, + }, + Next: resources.Next{Page: nextPage}, + } + return &resp, nil +} + +// CreateRole creates a single Role. +func (s *rolesService) CreateRole(ctx context.Context, role *resources.Role) (*resources.Role, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + roleInfo, err := s.jimm.GetRoleManager().AddRole(ctx, user, role.Name) + if err != nil { + return nil, err + } + return &resources.Role{Id: &roleInfo.UUID, Name: roleInfo.Name}, nil +} + +// GetRole returns a single Role identified by `roleId`. +func (s *rolesService) GetRole(ctx context.Context, roleId string) (*resources.Role, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + role, err := s.jimm.GetRoleManager().GetRoleByUUID(ctx, user, roleId) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return nil, v1.NewNotFoundError("failed to find role") + } + return nil, err + } + return &resources.Role{Id: &role.UUID, Name: role.Name}, nil +} + +// UpdateRole updates a Role. +func (s *rolesService) UpdateRole(ctx context.Context, role *resources.Role) (*resources.Role, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + if role.Id == nil { + return nil, v1.NewValidationError("missing role ID") + } + existingRole, err := s.jimm.GetRoleManager().GetRoleByUUID(ctx, user, *role.Id) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return nil, v1.NewNotFoundError("failed to find role") + } + return nil, err + } + err = s.jimm.GetRoleManager().RenameRole(ctx, user, existingRole.Name, role.Name) + if err != nil { + return nil, err + } + return &resources.Role{Id: &existingRole.UUID, Name: role.Name}, nil +} + +// DeleteRole deletes a Role identified by `roleId`. +// returns (true, nil) in case the role was successfully deleted. +// returns (false, error) in case something went wrong. +// implementors may want to return (false, nil) for idempotency cases. +func (s *rolesService) DeleteRole(ctx context.Context, roleId string) (bool, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + existingRole, err := s.jimm.GetRoleManager().GetRoleByUUID(ctx, user, roleId) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return false, nil + } + return false, err + } + err = s.jimm.GetRoleManager().RemoveRole(ctx, user, existingRole.Name) + if err != nil { + return false, err + } + return true, nil +} + +// GetRoleEntitlements returns a page of Entitlements for Role `roleId`. +func (s *rolesService) GetRoleEntitlements(ctx context.Context, roleId string, params *resources.GetRolesItemEntitlementsParams) (*resources.PaginatedResponse[resources.EntityEntitlement], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + ok := jimmnames.IsValidRoleId(roleId) + if !ok { + return nil, v1.NewValidationError("invalid role ID") + } + filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) + 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 + if err != nil { + return nil, err + } + originalToken := filter.Token() + resp := resources.PaginatedResponse[resources.EntityEntitlement]{ + Meta: resources.ResponseMeta{ + Size: len(tuples), + PageToken: &originalToken, + }, + Data: utils.ToEntityEntitlements(tuples), + } + if nextEntitlmentToken.String() != "" { + nextToken := nextEntitlmentToken.String() + resp.Next = resources.Next{ + PageToken: &nextToken, + } + } + return &resp, nil +} + +// PatchRoleEntitlements performs addition or removal of an Entitlement to/from a Role identified by `roleId`. +func (s *rolesService) PatchRoleEntitlements(ctx context.Context, roleId string, entitlementPatches []resources.RoleEntitlementsPatchItem) (bool, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + if !jimmnames.IsValidRoleId(roleId) { + return false, v1.NewValidationError("invalid role ID") + } + roleTag := jimmnames.NewRoleTag(roleId) + var toRemove []apiparams.RelationshipTuple + var toAdd []apiparams.RelationshipTuple + var errList utils.MultiErr + toTargetTag := func(entitlementPatch resources.RoleEntitlementsPatchItem) (names.Tag, error) { + return utils.ValidateDecomposedTag( + entitlementPatch.Entitlement.EntityType, + entitlementPatch.Entitlement.EntityId, + ) + } + for _, entitlementPatch := range entitlementPatches { + tag, err := toTargetTag(entitlementPatch) + if err != nil { + errList.AppendError(err) + continue + } + t := apiparams.RelationshipTuple{ + Object: ofganames.WithAssigneeRelation(roleTag), + Relation: entitlementPatch.Entitlement.Entitlement, + TargetObject: tag.String(), + } + if entitlementPatch.Op == resources.Add { + toAdd = append(toAdd, t) + } else { + toRemove = append(toRemove, t) + } + } + if err := errList.Error(); err != nil { + return false, err + } + if toAdd != nil { + err := s.jimm.AddRelation(ctx, user, toAdd) + if err != nil { + return false, err + } + } + if toRemove != nil { + err := s.jimm.RemoveRelation(ctx, user, toRemove) + if err != nil { + return false, err + } + } + return true, nil +} diff --git a/internal/jimmhttp/rebac_admin/roles_integration_test.go b/internal/jimmhttp/rebac_admin/roles_integration_test.go new file mode 100644 index 000000000..d7920aaba --- /dev/null +++ b/internal/jimmhttp/rebac_admin/roles_integration_test.go @@ -0,0 +1,232 @@ +// Copyright 2024 Canonical. + +package rebac_admin_test + +import ( + "context" + "fmt" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + gc "gopkg.in/check.v1" + + "github.com/canonical/jimm/v3/internal/jimmhttp/rebac_admin" + "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" +) + +type roleSuite struct { + jimmtest.JIMMSuite + roleSvc *rebac_admin.RolesService +} + +func (s *roleSuite) SetUpTest(c *gc.C) { + s.JIMMSuite.SetUpTest(c) + s.roleSvc = rebac_admin.NewRoleService(s.JIMM) +} + +var _ = gc.Suite(&roleSuite{}) + +func (s roleSuite) TestListRolesWithFilterIntegration(c *gc.C) { + ctx := context.Background() + for i := range 10 { + _, err := s.JIMM.RoleManager.AddRole(ctx, s.AdminUser, fmt.Sprintf("test-role-filter-%d", i)) + c.Assert(err, gc.IsNil) + } + + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + pageSize := 5 + page := 0 + params := &resources.GetRolesParams{Size: &pageSize, Page: &page} + res, err := s.roleSvc.ListRoles(ctx, params) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + c.Assert(res.Meta.Size, gc.Equals, 5) + + match := "role-filter-1" + params.Filter = &match + res, err = s.roleSvc.ListRoles(ctx, params) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + c.Assert(len(res.Data), gc.Equals, 1) + + match = "role" + params.Filter = &match + res, err = s.roleSvc.ListRoles(ctx, params) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + c.Assert(len(res.Data), gc.Equals, pageSize) +} + +func (s roleSuite) TestGetRoleEntitlementsIntegration(c *gc.C) { + ctx := context.Background() + role, err := s.JIMM.RoleManager.AddRole(ctx, s.AdminUser, "test-role") + c.Assert(err, gc.IsNil) + tuple := openfga.Tuple{ + Object: ofganames.ConvertTagWithRelation(jimmnames.NewRoleTag(role.UUID), ofganames.AssigneeRelation), + Relation: ofganames.AdministratorRelation, + } + var tuples []openfga.Tuple + for i := range 3 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewModelTag(fmt.Sprintf("test-model-%d", i))) + tuples = append(tuples, t) + } + for i := range 3 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewControllerTag(fmt.Sprintf("test-controller-%d", i))) + tuples = append(tuples, t) + } + err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + emptyPageToken := "" + req := resources.GetRolesItemEntitlementsParams{NextPageToken: &emptyPageToken} + var entitlements []resources.EntityEntitlement + res, err := s.roleSvc.GetRoleEntitlements(ctx, role.UUID, &req) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + entitlements = append(entitlements, res.Data...) + c.Assert(entitlements, gc.HasLen, 6) + modelEntitlementCount := 0 + controllerEntitlementCount := 0 + for _, entitlement := range entitlements { + c.Assert(entitlement.Entitlement, gc.Equals, ofganames.AdministratorRelation.String()) + c.Assert(entitlement.EntityId, gc.Matches, `test-(model|controller)-\d`) + switch entitlement.EntityType { + case openfga.ModelType.String(): + modelEntitlementCount++ + case openfga.ControllerType.String(): + controllerEntitlementCount++ + default: + c.Logf("Unexpected entitlement found of type %s", entitlement.EntityType) + c.FailNow() + } + } + c.Assert(modelEntitlementCount, gc.Equals, 3) + c.Assert(controllerEntitlementCount, gc.Equals, 3) +} + +// patchRoleEntitlementTestEnv is used to create entries in JIMM's database. +// The roleSuite does not spin up a Juju controller so we cannot use +// regular JIMM methods to create resources. It is also necessary to have resources +// present in the database in order for ListRelationshipTuples to work correctly. +const patchRoleEntitlementTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-cloud-region +cloud-credentials: +- owner: alice@canonical.com + name: cred-1 + cloud: test-cloud +controllers: +- name: controller-1 + uuid: 00000001-0000-0000-0000-000000000001 + cloud: test-cloud + region: test-cloud-region +models: +- name: model-1 + uuid: 00000002-0000-0000-0000-000000000001 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-2 + uuid: 00000002-0000-0000-0000-000000000002 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-3 + uuid: 00000003-0000-0000-0000-000000000003 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-4 + uuid: 00000004-0000-0000-0000-000000000004 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +` + +// TestPatchRoleEntitlementsIntegration creates 4 models and verifies that relations from a role to these models can be added/removed. +func (s roleSuite) TestPatchRoleEntitlementsIntegration(c *gc.C) { + ctx := context.Background() + tester := jimmtest.GocheckTester{C: c} + env := jimmtest.ParseEnvironment(tester, patchRoleEntitlementTestEnv) + env.PopulateDB(tester, s.JIMM.Database) + oldModels := []string{env.Models[0].UUID, env.Models[1].UUID} + newModels := []string{env.Models[2].UUID, env.Models[3].UUID} + + role, err := s.JIMM.RoleManager.AddRole(ctx, s.AdminUser, "test-role") + c.Assert(err, gc.IsNil) + tuple := openfga.Tuple{ + Object: ofganames.ConvertTagWithRelation(jimmnames.NewRoleTag(role.UUID), ofganames.AssigneeRelation), + Relation: ofganames.AdministratorRelation, + } + + var tuples []openfga.Tuple + for i := range 2 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewModelTag(oldModels[i])) + tuples = append(tuples, t) + } + err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + allowed, err := s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[0], false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) + // Above we have added granted the role with administrator permission to 2 models. + // Below, we will request those 2 relations to be removed and add 2 different relations. + + entitlementPatches := []resources.RoleEntitlementsPatchItem{ + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: newModels[0], + EntityType: openfga.ModelType.String(), + }, Op: resources.Add}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: newModels[1], + EntityType: openfga.ModelType.String(), + }, Op: resources.Add}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: oldModels[0], + EntityType: openfga.ModelType.String(), + }, Op: resources.Remove}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: oldModels[1], + EntityType: openfga.ModelType.String(), + }, Op: resources.Remove}, + } + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + res, err := s.roleSvc.PatchRoleEntitlements(ctx, role.UUID, entitlementPatches) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Equals, true) + + for i := range 2 { + allowed, err = s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[i], false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, false) + } + for i := range 2 { + newTuple := tuples[0] + newTuple.Target = ofganames.ConvertTag(names.NewModelTag(newModels[i])) + allowed, err = s.JIMM.OpenFGAClient.CheckRelation(ctx, newTuple, false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) + } +} diff --git a/internal/jimmhttp/rebac_admin/roles_test.go b/internal/jimmhttp/rebac_admin/roles_test.go new file mode 100644 index 000000000..5848876b7 --- /dev/null +++ b/internal/jimmhttp/rebac_admin/roles_test.go @@ -0,0 +1,262 @@ +// Copyright 2024 Canonical. + +package rebac_admin_test + +import ( + "context" + "errors" + "testing" + + "github.com/canonical/ofga" + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimm" + "github.com/canonical/jimm/v3/internal/jimmhttp/rebac_admin" + "github.com/canonical/jimm/v3/internal/openfga" + "github.com/canonical/jimm/v3/internal/testutils/jimmtest" + "github.com/canonical/jimm/v3/internal/testutils/jimmtest/mocks" + "github.com/canonical/jimm/v3/pkg/api/params" +) + +func TestCreateRole(t *testing.T) { + c := qt.New(t) + var addErr error + roleManager := mocks.RoleManager{ + AddRole_: func(ctx context.Context, user *openfga.User, name string) (*dbmodel.RoleEntry, error) { + return &dbmodel.RoleEntry{UUID: "test-uuid", Name: name}, addErr + }, + } + jimm := jimmtest.JIMM{ + GetRoleManager_: func() jimm.RoleManager { + return roleManager + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + roleSvc := rebac_admin.NewRoleService(&jimm) + resp, err := roleSvc.CreateRole(ctx, &resources.Role{Name: "new-role"}) + c.Assert(err, qt.IsNil) + c.Assert(*resp.Id, qt.Equals, "test-uuid") + c.Assert(resp.Name, qt.Equals, "new-role") + addErr = errors.New("foo") + _, err = roleSvc.CreateRole(ctx, &resources.Role{Name: "new-role"}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestUpdateRole(t *testing.T) { + c := qt.New(t) + roleID := "role-id" + var renameErr error + roleManager := mocks.RoleManager{ + GetRoleByUUID_: func(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.RoleEntry, error) { + return &dbmodel.RoleEntry{UUID: roleID, Name: "test-role"}, nil + }, + RenameRole_: func(ctx context.Context, user *openfga.User, oldName, newName string) error { + if oldName != "test-role" { + return errors.New("invalid old role name") + } + return renameErr + }, + } + jimm := jimmtest.JIMM{ + GetRoleManager_: func() jimm.RoleManager { + return roleManager + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + roleSvc := rebac_admin.NewRoleService(&jimm) + _, err := roleSvc.UpdateRole(ctx, &resources.Role{Name: "new-role"}) + c.Assert(err, qt.ErrorMatches, ".*missing role ID") + resp, err := roleSvc.UpdateRole(ctx, &resources.Role{Id: &roleID, Name: "new-role"}) + c.Assert(err, qt.IsNil) + c.Assert(resp, qt.DeepEquals, &resources.Role{Id: &roleID, Name: "new-role"}) + renameErr = errors.New("foo") + _, err = roleSvc.UpdateRole(ctx, &resources.Role{Id: &roleID, Name: "new-role"}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestListRoles(t *testing.T) { + c := qt.New(t) + var listErr error + returnedRoles := []dbmodel.RoleEntry{ + {Name: "role-1"}, + {Name: "role-2"}, + {Name: "role-3"}, + } + roleManager := mocks.RoleManager{ + ListRoles_: func(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]dbmodel.RoleEntry, error) { + return returnedRoles, listErr + }, + CountRoles_: func(ctx context.Context, user *openfga.User) (int, error) { + return 10, nil + }, + } + jimm := jimmtest.JIMM{ + GetRoleManager_: func() jimm.RoleManager { + return roleManager + }, + } + expected := []resources.Role{} + id := "" + for _, role := range returnedRoles { + expected = append(expected, resources.Role{Name: role.Name, Id: &id}) + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + roleSvc := rebac_admin.NewRoleService(&jimm) + resp, err := roleSvc.ListRoles(ctx, &resources.GetRolesParams{}) + c.Assert(err, qt.IsNil) + c.Assert(resp.Data, qt.DeepEquals, expected) + c.Assert(*resp.Meta.Page, qt.Equals, 0) + c.Assert(resp.Meta.Size, qt.Equals, len(expected)) + c.Assert(*resp.Meta.Total, qt.Equals, 10) + c.Assert(*resp.Next.Page, qt.Equals, 1) + listErr = errors.New("foo") + _, err = roleSvc.ListRoles(ctx, &resources.GetRolesParams{}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestDeleteRole(t *testing.T) { + c := qt.New(t) + var deleteErr error + RoleManager := mocks.RoleManager{ + GetRoleByUUID_: func(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.RoleEntry, error) { + return &dbmodel.RoleEntry{UUID: uuid, Name: "test-role"}, nil + }, + RemoveRole_: func(ctx context.Context, user *openfga.User, name string) error { + if name != "test-role" { + return errors.New("invalid name provided") + } + return deleteErr + }, + } + jimm := jimmtest.JIMM{ + GetRoleManager_: func() jimm.RoleManager { + return RoleManager + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + roleSvc := rebac_admin.NewRoleService(&jimm) + res, err := roleSvc.DeleteRole(ctx, "role-id") + c.Assert(res, qt.IsTrue) + c.Assert(err, qt.IsNil) + deleteErr = errors.New("foo") + _, err = roleSvc.DeleteRole(ctx, "role-id") + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestGetRoleEntitlements(t *testing.T) { + c := qt.New(t) + var listRelationsErr error + var continuationToken string + testTuple := openfga.Tuple{ + Object: &ofga.Entity{Kind: "user", ID: "foo"}, + Relation: ofga.Relation("member"), + Target: &ofga.Entity{Kind: "role", ID: "my-role"}, + } + 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 + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + roleSvc := rebac_admin.NewRoleService(&jimm) + + _, err := roleSvc.GetRoleEntitlements(ctx, "invalid-role-id", nil) + c.Assert(err, qt.ErrorMatches, ".* invalid role ID") + + continuationToken = "random-token" + res, err := roleSvc.GetRoleEntitlements(ctx, uuid.New().String(), &resources.GetRolesItemEntitlementsParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Data, qt.HasLen, 1) + c.Assert(*res.Next.PageToken, qt.Equals, "random-token") + + continuationToken = "" + res, err = roleSvc.GetRoleEntitlements(ctx, uuid.New().String(), &resources.GetRolesItemEntitlementsParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Next.PageToken, qt.IsNil) + + nextToken := "some-token" + res, err = roleSvc.GetRoleEntitlements(ctx, uuid.New().String(), &resources.GetRolesItemEntitlementsParams{NextToken: &nextToken}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + + listRelationsErr = errors.New("foo") + _, err = roleSvc.GetRoleEntitlements(ctx, uuid.New().String(), &resources.GetRolesItemEntitlementsParams{}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestPatchRoleEntitlements(t *testing.T) { + c := qt.New(t) + var patchTuplesErr error + 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 + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + roleSvc := rebac_admin.NewRoleService(&jimm) + + _, err := roleSvc.PatchRoleEntitlements(ctx, "invalid-role-id", nil) + c.Assert(err, qt.ErrorMatches, ".* invalid role ID") + + newUUID := uuid.New() + operations := []resources.RoleEntitlementsPatchItem{ + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: newUUID.String(), + EntityType: "model", + }, Op: resources.Add}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: newUUID.String(), + EntityType: "model", + }, Op: resources.Remove}, + } + res, err := roleSvc.PatchRoleEntitlements(ctx, newUUID.String(), operations) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsTrue) + + operationsWithInvalidTag := []resources.RoleEntitlementsPatchItem{ + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: "foo", + EntityType: "invalidType", + }, Op: resources.Add}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: "foo1", + EntityType: "invalidType2", + }, Op: resources.Add}, + } + _, err = roleSvc.PatchRoleEntitlements(ctx, newUUID.String(), operationsWithInvalidTag) + c.Assert(err, qt.ErrorMatches, `\"invalidType-foo\" is not a valid tag\n\"invalidType2-foo1\" is not a valid tag`) + + patchTuplesErr = errors.New("foo") + _, err = roleSvc.PatchRoleEntitlements(ctx, newUUID.String(), operations) + c.Assert(err, qt.ErrorMatches, "foo") +} diff --git a/internal/openfga/names/common.go b/internal/openfga/names/common.go index 199333cc4..8a7e92c52 100644 --- a/internal/openfga/names/common.go +++ b/internal/openfga/names/common.go @@ -11,3 +11,9 @@ import ( func WithMemberRelation(groupTag names.GroupTag) string { return groupTag.String() + "#" + MemberRelation.String() } + +// WithAssigneeRelation is a convenience function for role tags to return the tag's string +// with an assignee relation, commonly used when assigning role relations. +func WithAssigneeRelation(groupTag names.RoleTag) string { + return groupTag.String() + "#" + AssigneeRelation.String() +} diff --git a/internal/testutils/jimmtest/suite.go b/internal/testutils/jimmtest/suite.go index 41b947ccf..332486d26 100644 --- a/internal/testutils/jimmtest/suite.go +++ b/internal/testutils/jimmtest/suite.go @@ -275,6 +275,13 @@ func (s *JIMMSuite) AddGroup(c *gc.C, groupName string) dbmodel.GroupEntry { return *group } +func (s *JIMMSuite) AddRole(c *gc.C, roleName string) dbmodel.RoleEntry { + ctx := context.Background() + role, err := s.JIMM.RoleManager.AddRole(ctx, s.AdminUser, roleName) + c.Assert(err, gc.Equals, nil) + return *role +} + // EnableDeviceFlow allows a test to use the device flow. // Call this non-blocking function before login to ensure the device flow won't block. //