Skip to content

Commit

Permalink
Merge pull request juju#16556 from anvial/JUJU-4896-service-layer-for…
Browse files Browse the repository at this point in the history
…-users-domain

juju#16556

This PR introduces the service layer for the User domain.

The interface represents get/add/remove API to operate with users.

The layers support two types of authentication:
- with password
- activation key

The layer also supports setting and resetting user passwords.

The design is extendable to support new auth methods in the future.

_Note_: The regex for the user name should be synced with the `juju/names` package, which would be done in a separate PR. 

## Checklist

- [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

## QA steps

```sh
go test github.com/juju/juju/domain/user/service/... -gocheck.v
```

## Links

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



[JUJU-4896]: https://warthogs.atlassian.net/browse/JUJU-4896?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
jujubot authored Nov 21, 2023
2 parents 96edcbb + e033f44 commit 03ceaf7
Show file tree
Hide file tree
Showing 10 changed files with 1,453 additions and 13 deletions.
22 changes: 22 additions & 0 deletions core/user/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2023 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package user

import (
"time"
)

type User struct {
// CreatedAt is the time that the user was created at.
CreatedAt time.Time

// DisplayName is a user-friendly name represent the user as.
DisplayName string

// Name is the username of the user.
Name string

// CreatorUUID is the associated user that created this user.
CreatorUUID string
}
2 changes: 1 addition & 1 deletion domain/schema/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ func ControllerDDL() *schema.Schema {
changeLogTriggersForTable("upgrade_info", "uuid", tableUpgradeInfo),
changeLogTriggersForTable("upgrade_info_controller_node", "upgrade_info_uuid", tableUpgradeInfoControllerNode),
autocertCacheSchema,
userSchema,
objectStoreMetadataSchema,
userSchema,
}

schema := schema.New()
Expand Down
10 changes: 5 additions & 5 deletions domain/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,16 @@ func (s *schemaSuite) TestControllerDDLApply(c *gc.C) {
"upgrade_info_controller_node",
"upgrade_state_type",

// Object store metadata
"object_store_metadata",
"object_store_metadata_path",
"object_store_metadata_hash_type",

// Users
"user",
"user_authentication",
"user_password",
"user_activation_key",

// Object store metadata
"object_store_metadata",
"object_store_metadata_path",
"object_store_metadata_hash_type",
)
c.Assert(readTableNames(c, s.DB()), jc.SameContents, expected.Union(internalTableNames).SortedValues())
}
Expand Down
28 changes: 28 additions & 0 deletions domain/user/errors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2023 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package errors

import (
"github.com/juju/errors"
)

const (
// NotFound describes an error that occurs when the user being requested does
// not exist.
NotFound = errors.ConstError("user not found")

// UserCreatorUuidNotFound describes an error that occurs when a user's creator UUID,
// the user that created the user in question, does not exist.
UserCreatorUuidNotFound = errors.ConstError("user creator UUID not found")

// UsernameNotValid describes an error that occurs when a supplied username
// is not valid.
// Examples of this include illegal characters or usernames that are not of
// sufficient length.
UsernameNotValid = errors.ConstError("username not valid")

// AlreadyExists describes an error that occurs when the user being
// created already exists.
AlreadyExists = errors.ConstError("user already exists")
)
16 changes: 16 additions & 0 deletions domain/user/service/package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2023 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package service

import (
"testing"

gc "gopkg.in/check.v1"
)

//go:generate go run go.uber.org/mock/mockgen -package service -destination state_mock_test.go github.com/juju/juju/domain/user/service State

func TestPackage(t *testing.T) {
gc.TestingT(t)
}
289 changes: 289 additions & 0 deletions domain/user/service/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
// Copyright 2023 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package service

import (
"context"
"crypto/rand"
"fmt"
"regexp"

"github.com/juju/juju/core/user"
usererrors "github.com/juju/juju/domain/user/errors"
"github.com/juju/juju/internal/auth"
)

// State describes retrieval and persistence methods for user identify and
// authentication.
type State interface {
// AddUser will add a new user to the database. If the user already exists
// an error that satisfies usererrors.AlreadyExists will be returned. If the
// users creator is set and does not exist then a error that satisfies
// usererrors.UserCreatorNotFound will be returned.
AddUser(context.Context, user.User) error

// AddUserWithPasswordHash will add a new user to the database with the
// provided password hash and salt. If the user already exists an error that
// satisfies usererrors.AlreadyExists will be returned. If the users creator
// does not exist or has been previously removed a error that satisfies
// usererrors.UserCreatorNotFound will be returned.
AddUserWithPasswordHash(context.Context, user.User, string, []byte) error

// AddUserWithActivationKey will add a new user to the database with the
// provided activation key. If the user already exists an error that
// satisfies usererrors.AlreadyExists will be returned. if the users creator
// does not exist or has been previously removed a error that satisfies
// usererrors.UserCreatorNotFound will be returned.
AddUserWithActivationKey(context.Context, user.User, []byte) error

// GetUser will retrieve the user specified by name from the database where
// the user is active and has not been removed. If the user does not exist
// or is deleted an error that satisfies usererrors.NotFound will be
// returned.
GetUser(context.Context, string) (user.User, error)

// RemoveUser marks the user as removed. This obviates the ability of a user
// to function, but keeps the user retaining provenance, i.e. auditing.
// RemoveUser will also remove any credentials and activation codes for the
// user. If no user exists for the given name then a error that satisfies
// usererrors.NotFound will be returned.
RemoveUser(context.Context, string) error

// SetActivationKey removes any active passwords for the user and sets the
// activation key. If no user is found for the supplied name a error
// is returned that satisfies usererrors.NotFound.
SetActivationKey(context.Context, string, []byte) error

// SetPasswordHash removes any active activation keys and sets the user
// password hash and salt. If no user is found for the supplied name a error
// is returned that satisfies usererrors.NotFound.
SetPasswordHash(ctx context.Context, username string, passwordHash string, salt []byte) error
}

// Service provides the API for working with users.
type Service struct {
st State
}

const (
// activationKeyLength is the number of bytes contained with an activation
// key.
activationKeyLength = 32

// usernameValidationRegex is the regex used to validate that user names are
// valid for consumption by Juju. Usernames must be 1 or more runes long,
// can contain any unicode rune from the letter/number class and may contain
// zero or more of .,+ or - runes as long as they don't appear at the
// start or end of the username. Usernames can be a maximum of 255
// characters long.
usernameValidationRegex = "^([\\pL\\pN]|[\\pL\\pN][\\pL\\pN.+-]{0,253}[\\pL\\pN])$"
)

var (
// validUserName is a compiled regex that is used to validate that a user
validUserName = regexp.MustCompile(usernameValidationRegex)
)

// NewService returns a new Service for interacting with the underlying user
// state.
func NewService(st State) *Service {
return &Service{st: st}
}

// GetUser will find and return the user associated with name. If there is no
// user for the user name then a error that satisfies usererrors.NotFound will
// be returned. If supplied with a invalid user name then a error that satisfies
// usererrors.UsernameNotValid will be returned.
//
// GetUser will not return users that have been previously removed.
func (s *Service) GetUser(
ctx context.Context,
name string,
) (user.User, error) {
if err := ValidateUsername(name); err != nil {
return user.User{}, fmt.Errorf("username %q: %w", name, err)
}

u, err := s.st.GetUser(ctx, name)
if err != nil {
return user.User{}, fmt.Errorf("getting user %q: %w", name, err)
}

return u, nil
}

// ValidateUsername takes a user name and validates that the user name is
// conformant to our regex rules defined in usernameValidationRegex. If a user
// name is not valid a error is returned that satisfies
// usererrors.UsernameNotValid.
//
// Usernames must be one or more runes long, can contain any unicode rune from
// the letter or number class and may contain zero or more of .,+ or - runes as
// long as they don't appear at the start or end of the username. Usernames can
// be a maximum length of 255 characters.
func ValidateUsername(name string) error {
if !validUserName.MatchString(name) {
return fmt.Errorf("%w %q", usererrors.UsernameNotValid, name)
}
return nil
}

// AddUser will add a new user to the database.
//
// The following error types are possible from this function:
// - usererrors.UsernameNotValid: When the username supplied is not valid.
// - usererrors.AlreadyExists: If a user with the supplied name already exists.
// - usererrors.UserCreatorNotFound: If a creator has been supplied for the user
// and the creator does not exist.
func (s *Service) AddUser(ctx context.Context, user user.User) error {
if err := ValidateUsername(user.Name); err != nil {
return fmt.Errorf("username %q: %w", user.Name, err)
}

if err := s.st.AddUser(ctx, user); err != nil {
return fmt.Errorf("adding user %q: %w", user.Name, err)
}
return nil
}

// AddUserWithPassword will add a new user to the database with a password. The
// password passed to this function will have it's Destroy() function called
// every time.
//
// The following error types are possible from this function:
// - usererrors.UsernameNotValid: When the username supplied is not valid.
// - usererrors.AlreadyExists: If a user with the supplied name already exists.
// - usererrors.UserCreatorNotFound: If a creator has been supplied for the user
// and the creator does not exist.
// - internal/auth.ErrPasswordDestroyed: If the supplied password has already
// been destroyed.
// - internal/auth.ErrPasswordNotValid: If the password supplied is not valid.
func (s *Service) AddUserWithPassword(ctx context.Context, user user.User, password auth.Password) error {
defer password.Destroy()
if err := ValidateUsername(user.Name); err != nil {
return fmt.Errorf("username %q: %w", user.Name, err)
}

salt, err := auth.NewSalt()
if err != nil {
return fmt.Errorf("adding user %q, generating password salt: %w", user.Name, err)
}

pwHash, err := auth.HashPassword(password, salt)
if err != nil {
return fmt.Errorf("adding user %q, hashing password: %w", user.Name, err)
}

if err = s.st.AddUserWithPasswordHash(ctx, user, pwHash, salt); err != nil {
return fmt.Errorf("adding user %q with password: %w", user.Name, err)
}
return nil
}

// AddUserWithActivationKey will add a new user to the database with an activation key.
//
// The following error types are possible from this function:
// - usererrors.UsernameNotValid: When the username supplied is not valid.
// - usererrors.AlreadyExists: If a user with the supplied name already exists.
// - usererrors.UserCreatorNotFound: If a creator has been supplied for the user
// and the creator does not exist.
func (s *Service) AddUserWithActivationKey(ctx context.Context, user user.User) ([]byte, error) {
if err := ValidateUsername(user.Name); err != nil {
return nil, fmt.Errorf("username %q with activation key: %w", user.Name, err)
}

activationKey, err := generateActivationKey()
if err != nil {
return nil, fmt.Errorf("generating activation key for user %q: %w", user.Name, err)
}

if err = s.st.AddUserWithActivationKey(ctx, user, activationKey); err != nil {
return nil, fmt.Errorf("adding user %q with activation key: %w", user.Name, err)
}
return activationKey, nil
}

// RemoveUser marks the user as removed and removes any credentials or
// activation codes for the current users. Once a user is removed they are no
// longer usable in Juju and should never be un removed.
//
// The following error types are possible from this function:
// - usererrors.UsernameNotValid: When the username supplied is not valid.
// - usererrors.NotFound: If no user by the given name exists.
func (s *Service) RemoveUser(ctx context.Context, name string) error {
if err := ValidateUsername(name); err != nil {
return fmt.Errorf("username %q: %w", name, err)
}
if err := s.st.RemoveUser(ctx, name); err != nil {
return fmt.Errorf("removing user %q: %w", name, err)
}
return nil
}

// SetPassword changes the users password to the new value and removes any
// active activation keys for the users. The password passed to this function
// will have it's Destroy() function called every time.
//
// The following error types are possible from this function:
// - usererrors.UsernameNotValid: When the username supplied is not valid.
// - usererrors.NotFound: If no user by the given name exists.
// - internal/auth.ErrPasswordDestroyed: If the supplied password has already
// been destroyed.
// - internal/auth.ErrPasswordNotValid: If the password supplied is not valid.
func (s *Service) SetPassword(
ctx context.Context,
name string,
password auth.Password,
) error {
defer password.Destroy()
if err := ValidateUsername(name); err != nil {
return fmt.Errorf("username %q: %w", name, err)
}

salt, err := auth.NewSalt()
if err != nil {
return fmt.Errorf("setting password for user %q, generating password salt: %w", name, err)
}

pwHash, err := auth.HashPassword(password, salt)
if err != nil {
return fmt.Errorf("setting password for user %q, hashing password: %w", name, err)
}

if err = s.st.SetPasswordHash(ctx, name, pwHash, salt); err != nil {
return fmt.Errorf("setting password for user %q: %w", name, err)
}
return nil
}

// ResetPassword will remove any active passwords for a user and generate a new
// activation key for the user to use to set a new password.
// The following error types are possible from this function:
// - usererrors.UsernameNotValid: When the username supplied is not valid.
// - usererrors.NotFound: If no user by the given name exists.
func (s *Service) ResetPassword(ctx context.Context, name string) ([]byte, error) {
if err := ValidateUsername(name); err != nil {
return nil, fmt.Errorf("username %q: %w", name, err)
}

activationKey, err := generateActivationKey()
if err != nil {
return nil, fmt.Errorf("generating activation key for user %q: %w", name, err)
}

if err = s.st.SetActivationKey(ctx, name, activationKey); err != nil {
return nil, fmt.Errorf("setting activation key for user %q: %w", name, err)
}
return activationKey, nil
}

// generateActivationKey is responsible for generating a new activation key that
// can be used for supplying to a user.
func generateActivationKey() ([]byte, error) {
var activationKey [activationKeyLength]byte
if _, err := rand.Read(activationKey[:]); err != nil {
return nil, err
}
return activationKey[:], nil
}
Loading

0 comments on commit 03ceaf7

Please sign in to comment.