Skip to content

Commit

Permalink
Merge pull request juju#17970 from Aflynn50/model-last-login-migration
Browse files Browse the repository at this point in the history
juju#17970

Import and export model user permissions. In mongo there was the concept
of model users, these have been reduced to model user permissions in
4.0 since the controller holds all extra information about the users.

<!-- 
The PR title should match: <type>(optional <scope>): <description>.

Please also ensure all commits in this PR comply with our conventional commits specification:
https://docs.google.com/document/d/1SYUo9G7qZ_jdoVXpUVamS5VCgHmtZ0QA-wZxKoMS-C0 
-->

<!-- Why this change is needed and what it does. -->

## Checklist

<!-- If an item is not applicable, use `~strikethrough~`. -->

- [x] Code style: imports ordered, good names, simple structure, etc
- [x] Comments saying why design decisions were made
- [x] Go unit tests, with comments saying what you're testing
- [x] [Integration tests](https://github.com/juju/juju/tree/main/tests), with comments saying what you're testing
- [ ] [doc.go](https://discourse.charmhub.io/t/readme-in-packages/451) added or updated in changed packages

## QA steps
Test 4.0 to 4.0
```
juju bootstrap lxd source
juju bootstrap lxd target
juju switch source
juju add-model test
juju add-user jim
juju add-user bob

# Grant some permissions on the model
juju grant jim read test
juju grant bob write test

# Generate some last login times
juju change-user-password admin
juju change-user-password jim
juju change-user-password bob
juju logout && juju login -u jim
juju status
juju logout && juju login -u bob
juju status
juju logout && juju login -u admin

# Start migration
juju show-model test

juju switch source
juju 
```
Test 3.6 to 4.0
```
# Install 3.6
$ juju bootstrap lxd test36
$ juju add-model test-model 

# Repete steps above to add users and generate last login times

$ juju show-model test-model-users
test-model-users:
 name: admin/test-model-users
 short-name: test-model-users
 model-uuid: 02138c75-326d-4f0c-8d30-e45ae6e5f6c1
 model-type: iaas
 controller-uuid: 42a80563-f6cf-4d02-8e53-73e888bb3711
 controller-name: test36
 is-controller: false
 owner: admin
 cloud: lxd
 region: default
 type: lxd
 life: alive
 status:
 current: available
 since: 3 minutes ago
 users:
 admin:
 display-name: admin
 access: admin
 last-connection: just now
 bob:
 access: write
 last-connection: 1 minute ago
 jim:
 access: read
 last-connection: 28 seconds ago
 sla: unsupported
 agent-version: 3.6-beta3.1
 credential:
 name: lxd
 owner: admin
 cloud: lxd
 validity-check: valid
 supported-features:
 - name: juju
 description: the version of Juju used by the model
 version: 3.6-beta3.1

# Switch to target 4.0 controller
$ juju switch target
$ juju migrate test36:test-model-users target
# Wait for migration to complete
$ juju show-model test-model-users
test-model-users:
 name: admin/test-model-users
 short-name: test-model-users
 model-uuid: 02138c75-326d-4f0c-8d30-e45ae6e5f6c1
 model-type: iaas
 controller-uuid: 294ed49a-d474-4e0a-8de2-15052e921a93
 controller-name: target
 is-controller: false
 owner: admin
 cloud: lxd
 region: default
 type: lxd
 life: alive
 status:
 current: available
 since: 34 seconds ago
 users:
 admin:
 display-name: admin
 access: admin
 last-connection: 2 minutes ago
 bob:
 access: write
 last-connection: 3 minutes ago
 jim:
 access: read
 last-connection: 2 minutes ago
# Do an upgrade model to check its all fine
$ juju upgrade-model
juju show-model admin/test-model-users
test-model-users:
 ...
 users:
 admin:
 display-name: admin
 access: admin
 last-connection: 3 minutes ago
 bob:
 access: write
 last-connection: 12 minutes ago
 jim:
 access: read
 last-connection: 12 minutes ago
...
 agent-version: 4.0-beta5.1
...
 supported-features:
 - name: juju
 description: the version of Juju used by the model
 version: 4.0-beta5.1

# Login with bob to check he can see the controller

$ juju logout
$ juju login -u bob
$ juju show-model admin/test-model-users
...
 users:
 bob:
 access: write
 last-connection: 16 minutes ago
...
```
<!-- Describe steps to verify that the change works. -->


<!-- How it affects user workflow (CLI or API). -->

## Links

<!-- Link to all relevant specification, documentation, bug, issue or JIRA card. -->


**Jira card:** [JUJU-6512](https://warthogs.atlassian.net/browse/JUJU-6512)



[JUJU-6512]: https://warthogs.atlassian.net/browse/JUJU-6512?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
jujubot authored Aug 30, 2024
2 parents f688d7a + 623b110 commit 4658cde
Show file tree
Hide file tree
Showing 21 changed files with 903 additions and 269 deletions.
98 changes: 98 additions & 0 deletions domain/access/modelmigration/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package modelmigration

import (
"context"
"time"

"github.com/juju/description/v8"
"github.com/juju/errors"

"github.com/juju/juju/core/logger"
coremodel "github.com/juju/juju/core/model"
"github.com/juju/juju/core/modelmigration"
corepermission "github.com/juju/juju/core/permission"
"github.com/juju/juju/core/user"
accesserrors "github.com/juju/juju/domain/access/errors"
"github.com/juju/juju/domain/access/service"
"github.com/juju/juju/domain/access/state"
)

// RegisterExport registers the export operations with the given coordinator.
func RegisterExport(coordinator Coordinator, logger logger.Logger) {
coordinator.Add(&exportOperation{
logger: logger,
})
}

// ExportService provides a subset of the access domain
// service methods needed for model permissions export.
type ExportService interface {
// ReadAllUserAccessForTarget return a slice of user access for all users
// with access to the given target.
// An [errors.NotValid] error is returned if the target is not valid. Any
// errors from the state layer are passed through.
// An [accesserrors.PermissionNotFound] error is returned if no permissions
// can be found on the target.
ReadAllUserAccessForTarget(ctx context.Context, target corepermission.ID) ([]corepermission.UserAccess, error)
// LastModelLogin will return the last login time of the specified user.
// The following error types are possible from this function:
// - [accesserrors.UserNameNotValid] when the username is not valid.
// - [accesserrors.UserNotFound] when the user cannot be found.
// - [modelerrors.NotFound] if no model by the given modelUUID exists.
// - [accesserrors.UserNeverAccessedModel] if there is no record of the user
// accessing the model.
LastModelLogin(ctx context.Context, name user.Name, modelUUID coremodel.UUID) (time.Time, error)
}

// exportOperation describes a way to execute a migration for
// exporting model user permissions.
type exportOperation struct {
modelmigration.BaseOperation

logger logger.Logger
service ExportService
}

// Name returns the name of this operation.
func (e *exportOperation) Name() string {
return "export model user permissions"
}

// Setup implements Operation.
func (e *exportOperation) Setup(scope modelmigration.Scope) error {
e.service = service.NewService(
state.NewState(scope.ControllerDB(), e.logger),
)
return nil
}

// Execute the export, adding the model user permissions to the model.
func (e *exportOperation) Execute(ctx context.Context, model description.Model) error {
modelUUID := model.Tag().Id()
userAccesses, err := e.service.ReadAllUserAccessForTarget(ctx, corepermission.ID{
ObjectType: corepermission.Model,
Key: modelUUID,
})
if err != nil {
return errors.Annotatef(err, "getting user access on model")
}
for _, userAccess := range userAccesses {
lastModelLogin, err := e.service.LastModelLogin(ctx, userAccess.UserName, coremodel.UUID(modelUUID))
if err != nil && !errors.Is(err, accesserrors.UserNeverAccessedModel) {
return errors.Annotatef(err, "getting user last login on model")
}
arg := description.UserArgs{
Name: userAccess.UserTag,
DisplayName: userAccess.DisplayName,
CreatedBy: userAccess.CreatedBy,
DateCreated: userAccess.DateCreated,
LastConnection: lastModelLogin,
Access: string(userAccess.Access),
}
model.AddUser(arg)
}
return nil
}
102 changes: 102 additions & 0 deletions domain/access/modelmigration/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package modelmigration

import (
"context"
"time"

"github.com/juju/description/v8"
"github.com/juju/names/v5"
jc "github.com/juju/testing/checkers"
"go.uber.org/mock/gomock"
gc "gopkg.in/check.v1"

coremodel "github.com/juju/juju/core/model"
"github.com/juju/juju/core/permission"
"github.com/juju/juju/core/user"
)

type exportSuite struct {
coordinator *MockCoordinator
service *MockExportService
}

var _ = gc.Suite(&exportSuite{})

func (s *exportSuite) setupMocks(c *gc.C) *gomock.Controller {
ctrl := gomock.NewController(c)

s.coordinator = NewMockCoordinator(ctrl)
s.service = NewMockExportService(ctrl)

return ctrl
}

func (s *exportSuite) newExportOperation() *exportOperation {
return &exportOperation{
service: s.service,
}
}

func (s *exportSuite) TestExport(c *gc.C) {
defer s.setupMocks(c).Finish()

dst := description.NewModel(description.ModelArgs{})

bobTag := names.NewUserTag("bob")
bobName := user.NameFromTag(bobTag)
bazzaTag := names.NewUserTag("bazza")
bazzaName := user.NameFromTag(bazzaTag)
steveTag := names.NewUserTag("steve")

userAccesses := []permission.UserAccess{{
UserTag: bazzaTag,
Access: permission.ReadAccess,
CreatedBy: bobTag,
DateCreated: time.Now(),
DisplayName: bazzaName.Name(),
UserName: bazzaName,
}, {
UserTag: bobTag,
Access: permission.AdminAccess,
CreatedBy: steveTag,
DateCreated: time.Now(),
DisplayName: bobName.Name(),
UserName: bobName,
}}

s.service.EXPECT().ReadAllUserAccessForTarget(gomock.Any(), permission.ID{
ObjectType: permission.Model,
Key: dst.Tag().Id(),
}).Return(userAccesses, nil)

bobTime := time.Now().Truncate(time.Minute).UTC()
bazzaTime := time.Now().Truncate(time.Minute).UTC().Add(-time.Minute)
s.service.EXPECT().LastModelLogin(
gomock.Any(), bobName, coremodel.UUID(dst.Tag().Id()),
).Return(bobTime, nil)
s.service.EXPECT().LastModelLogin(
gomock.Any(), bazzaName, coremodel.UUID(dst.Tag().Id()),
).Return(bazzaTime, nil)

op := s.newExportOperation()
err := op.Execute(context.Background(), dst)
c.Assert(err, jc.ErrorIsNil)

users := dst.Users()
c.Assert(users, gc.HasLen, 2)
c.Check(users[0].Name(), gc.Equals, userAccesses[0].UserTag)
c.Check(users[0].Access(), gc.Equals, string(userAccesses[0].Access))
c.Check(users[0].CreatedBy(), gc.Equals, userAccesses[0].CreatedBy)
c.Check(users[0].DateCreated(), gc.Equals, userAccesses[0].DateCreated)
c.Check(users[0].DisplayName(), gc.Equals, userAccesses[0].DisplayName)
c.Check(users[0].LastConnection(), gc.Equals, bazzaTime)
c.Check(users[1].Name(), gc.Equals, userAccesses[1].UserTag)
c.Check(users[1].Access(), gc.Equals, string(userAccesses[1].Access))
c.Check(users[1].CreatedBy(), gc.Equals, userAccesses[1].CreatedBy)
c.Check(users[1].DateCreated(), gc.Equals, userAccesses[1].DateCreated)
c.Check(users[1].DisplayName(), gc.Equals, userAccesses[1].DisplayName)
c.Check(users[1].LastConnection(), gc.Equals, bobTime)
}
109 changes: 109 additions & 0 deletions domain/access/modelmigration/import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package modelmigration

import (
"context"
"time"

"github.com/juju/description/v8"
"github.com/juju/errors"

"github.com/juju/juju/core/logger"
coremodel "github.com/juju/juju/core/model"
"github.com/juju/juju/core/modelmigration"
corepermission "github.com/juju/juju/core/permission"
"github.com/juju/juju/core/user"
accesserrors "github.com/juju/juju/domain/access/errors"
"github.com/juju/juju/domain/access/service"
"github.com/juju/juju/domain/access/state"
)

// Coordinator is the interface that is used to add operations to a migration.
type Coordinator interface {
// Add adds the given operation to the migration.
Add(modelmigration.Operation)
}

// RegisterImport registers the import operations with the given coordinator.
func RegisterImport(coordinator Coordinator, logger logger.Logger) {
coordinator.Add(&importOperation{
logger: logger,
})
}

// ImportService provides a subset of the access domain
// service methods needed for model permissions import.
type ImportService interface {
// CreatePermission gives the user access per the provided spec.
// If the user provided does not exist or is marked removed,
// [accesserrors.PermissionNotFound] is returned.
// If the user provided exists but is marked disabled,
// [accesserrors.UserAuthenticationDisabled] is returned.
// If a permission for the user and target key already exists,
// [accesserrors.PermissionAlreadyExists] is returned.
CreatePermission(ctx context.Context, spec corepermission.UserAccessSpec) (corepermission.UserAccess, error)
// SetLastModelLogin will set the last login time for the user to the given
// value. The following error types are possible from this function:
// [accesserrors.UserNameNotValid] when the username supplied is not valid.
// [accesserrors.UserNotFound] when the user cannot be found.
// [modelerrors.NotFound] if no model by the given modelUUID exists.
SetLastModelLogin(ctx context.Context, name user.Name, modelUUID coremodel.UUID, time time.Time) error
}

type importOperation struct {
modelmigration.BaseOperation

logger logger.Logger
service ImportService
}

// Name returns the name of this operation.
func (i *importOperation) Name() string {
return "import model user permissions"
}

// Setup implements Operation.
func (i *importOperation) Setup(scope modelmigration.Scope) error {
i.service = service.NewService(
state.NewState(scope.ControllerDB(), i.logger))
return nil
}

// Execute the import on the model user permissions contained in the model.
func (i *importOperation) Execute(ctx context.Context, model description.Model) error {
modelUUID := model.Tag().Id()
for _, u := range model.Users() {
name := user.NameFromTag(u.Name())
access := corepermission.Access(u.Access())
if err := access.Validate(); err != nil {
return errors.Annotatef(err, "importing access for user %q", name)
}
_, err := i.service.CreatePermission(ctx, corepermission.UserAccessSpec{
AccessSpec: corepermission.AccessSpec{
Target: corepermission.ID{
ObjectType: corepermission.Model,
Key: modelUUID,
},
Access: access,
},
User: name,
})
if err != nil && !errors.Is(err, accesserrors.PermissionAlreadyExists) {
// If the permission already exists then it must be the model owner
// who is granted admin access when the model is created.
return errors.Annotatef(err, "creating permission for user %q", name)
}

lastLogin := u.LastConnection()
if !lastLogin.IsZero() {
err := i.service.SetLastModelLogin(ctx, name, coremodel.UUID(modelUUID), lastLogin)
if err != nil {
return errors.Annotatef(err, "setting model last login for user %q", name)
}
}

}
return nil
}
Loading

0 comments on commit 4658cde

Please sign in to comment.