-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request juju#16556 from anvial/JUJU-4896-service-layer-for…
…-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
Showing
10 changed files
with
1,453 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.