resource/user/user.go
// Copyright © 2016 Asteris, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package user
import (
"fmt"
"os/user"
"time"
"github.com/asteris-llc/converge/resource"
"github.com/pkg/errors"
"golang.org/x/net/context"
)
// State type for User
type State string
const (
// StatePresent indicates the user should be present
StatePresent State = "present"
// StateAbsent indicates the user should be absent
StateAbsent State = "absent"
// ShortForm layout for time parsing
ShortForm = "2006-01-02"
// MaxTime is the max representable time
MaxTime = "2038-01-19"
)
// User manages user users
type User struct {
// the configured username
Username string `export:"username"`
// the desired username
NewUsername string `export:"newusername"`
// the user id
UID string `export:"uid"`
// the group name
GroupName string `export:"groupname"`
// the group id
GID string `export:"gid"`
// the real name of the user
Name string `export:"name"`
// if the home directory should be created
CreateHome bool `export:"createhome"`
// the path to the skeleton directory
SkelDir string `export:"skeldir"`
// the path to the home directory
HomeDir string `export:"homedir"`
// if the contents of the home directory should be moved
MoveDir bool `export:"movedir"`
// the date the user account will be disabled
Expiry time.Time `export:"expiry"`
// configured the user state
State State `export:"state"`
system SystemUtils
}
// AddUserOptions are the options specified in the configuration to be used
// when adding a user
type AddUserOptions struct {
UID string
Group string
Comment string
CreateHome bool
SkelDir string
Directory string
Expiry string
}
// ModUserOptions are the options specified in the configuration to be used
// when modifying a user
type ModUserOptions struct {
Username string
UID string
Group string
Comment string
Directory string
MoveDir bool
Expiry string
}
// SystemUtils provides system utilities for user
type SystemUtils interface {
AddUser(userName string, options *AddUserOptions) error
DelUser(userName string) error
ModUser(userName string, options *ModUserOptions) error
LookupUserExpiry(userName string) (time.Time, error)
Lookup(userName string) (*user.User, error)
LookupID(userID string) (*user.User, error)
LookupGroup(groupName string) (*user.Group, error)
LookupGroupID(groupID string) (*user.Group, error)
}
// ErrUnsupported is used when a system is not supported
var ErrUnsupported = fmt.Errorf("user: not supported on this system")
// NewUser constructs and returns a new User
func NewUser(system SystemUtils) *User {
return &User{
system: system,
}
}
// Check if a user user exists
func (u *User) Check(context.Context, resource.Renderer) (resource.TaskStatus, error) {
// lookup the user by name
// ErrUnsupported is returned if the system is not supported
// Lookup returns user.UnknownUserError if the user is not found
userByName, nameErr := u.system.Lookup(u.Username)
status := resource.NewStatus()
if nameErr == ErrUnsupported {
status.RaiseLevel(resource.StatusFatal)
return status, ErrUnsupported
}
_, nameNotFound := nameErr.(user.UnknownUserError)
switch u.State {
case StatePresent:
switch {
case nameNotFound:
_, err := u.DiffAdd(status)
if err != nil {
return status, errors.Wrapf(err, "cannot add user %s", u.Username)
}
if resource.AnyChanges(status.Differences) {
status.AddMessage("add user")
}
case userByName != nil:
_, err := u.DiffMod(status, userByName)
if err != nil {
return status, errors.Wrapf(err, "cannot modify user %s", u.Username)
}
if resource.AnyChanges(status.Differences) {
status.AddMessage("modify user")
}
}
case StateAbsent:
err := u.DiffDel(status, userByName, nameNotFound)
if err != nil {
return status, errors.Wrapf(err, "cannot delete user %s", u.Username)
}
if resource.AnyChanges(status.Differences) {
status.AddMessage("delete user")
}
default:
status.RaiseLevel(resource.StatusFatal)
return status, fmt.Errorf("user: unrecognized state %v", u.State)
}
return status, nil
}
// Apply changes for user
func (u *User) Apply(context.Context) (resource.TaskStatus, error) {
// lookup the user by name
// ErrUnsupported is returned if the system is not supported
// Lookup returns user.UnknownUserError if the user is not found
userByName, nameErr := u.system.Lookup(u.Username)
status := resource.NewStatus()
if nameErr == ErrUnsupported {
status.RaiseLevel(resource.StatusFatal)
return status, ErrUnsupported
}
_, nameNotFound := nameErr.(user.UnknownUserError)
switch u.State {
case StatePresent:
switch {
case nameNotFound:
options, err := u.DiffAdd(status)
if err != nil {
return status, errors.Wrapf(err, "will not attempt to add user %s", u.Username)
}
if resource.AnyChanges(status.Differences) {
err = u.system.AddUser(u.Username, options)
if err != nil {
status.RaiseLevel(resource.StatusFatal)
status.AddMessage(fmt.Sprintf("error adding user %s", u.Username))
return status, errors.Wrap(err, "user add")
}
status.AddMessage(fmt.Sprintf("added user %s", u.Username))
if u.CreateHome {
u.createHomeDiffs(status)
}
}
case userByName != nil:
options, err := u.DiffMod(status, userByName)
if err != nil {
return status, errors.Wrapf(err, "will not attempt to modify user %s", u.Username)
}
if resource.AnyChanges(status.Differences) {
err = u.system.ModUser(u.Username, options)
if err != nil {
status.RaiseLevel(resource.StatusFatal)
status.AddMessage(fmt.Sprintf("error modifying user %s", u.Username))
return status, errors.Wrap(err, "user modify")
}
status.AddMessage(fmt.Sprintf("modified user %s", u.Username))
}
}
case StateAbsent:
err := u.DiffDel(status, userByName, nameNotFound)
if err != nil {
return status, errors.Wrapf(err, "will not attempt to delete user %s", u.Username)
}
if resource.AnyChanges(status.Differences) {
err = u.system.DelUser(u.Username)
if err != nil {
status.RaiseLevel(resource.StatusFatal)
status.AddMessage(fmt.Sprintf("error deleting user %s", u.Username))
return status, errors.Wrap(err, "user delete")
}
status.AddMessage(fmt.Sprintf("deleted user %s", u.Username))
}
default:
status.RaiseLevel(resource.StatusFatal)
return status, fmt.Errorf("user: unrecognized state %s", u.State)
}
return status, nil
}
// DiffAdd checks for differences between the current and desired state for the
// user to be added indicated by the User fields. The options to be used for the
// add command are set.
func (u *User) DiffAdd(status *resource.Status) (*AddUserOptions, error) {
options := new(AddUserOptions)
// if a group exists with the same name as the user being added, a groupname
// must also be indicated so the user may be added to that group
grp, _ := user.LookupGroup(u.Username)
if grp != nil && grp.Name == u.Username && u.GroupName == "" {
status.RaiseLevel(resource.StatusCantChange)
status.AddMessage("if you want to add this user to that group, use the groupname field")
return nil, fmt.Errorf("group %s exists", u.Username)
}
status.AddDifference("username", fmt.Sprintf("<%s>", string(StateAbsent)), u.Username, "")
if u.UID != "" {
usr, err := user.LookupId(u.UID)
_, uidNotFound := err.(user.UnknownUserIdError)
if uidNotFound {
options.UID = u.UID
status.AddDifference("uid", fmt.Sprintf("<%s>", string(StateAbsent)), u.UID, "")
} else if usr != nil {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("uid %s already exists", u.UID)
}
}
switch {
case u.GroupName != "":
grp, err := user.LookupGroup(u.GroupName)
if err != nil {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("group %s does not exist", u.GroupName)
} else if grp != nil {
options.Group = u.GroupName
status.AddDifference("group", fmt.Sprintf("<%s>", string(StateAbsent)), u.GroupName, "")
}
case u.GID != "":
grp, err := user.LookupGroupId(u.GID)
if err != nil {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("group gid %s does not exist", u.GID)
} else if grp != nil {
options.Group = u.GID
status.AddDifference("gid", fmt.Sprintf("<%s>", string(StateAbsent)), u.GID, "")
}
}
if u.Name != "" {
options.Comment = u.Name
status.AddDifference("comment", fmt.Sprintf("<%s>", string(StateAbsent)), u.Name, "")
}
if u.CreateHome {
dirDiff := u.HomeDir
if u.HomeDir == "" {
dirDiff = "<default home>"
}
options.CreateHome = true
status.AddDifference("create_home", fmt.Sprintf("<%s>", string(StateAbsent)), dirDiff, "")
if u.SkelDir != "" {
options.SkelDir = u.SkelDir
status.AddDifference("skel_dir contents", u.SkelDir, dirDiff, "")
}
}
if u.HomeDir != "" {
options.Directory = u.HomeDir
status.AddDifference("home_dir name", "<default home>", u.HomeDir, "")
}
if u.Expiry != (time.Time{}) {
options.Expiry = u.Expiry.Format(ShortForm)
status.AddDifference("expiry", "<default expiry>", options.Expiry, "")
}
status.RaiseLevelForDiffs()
return options, nil
}
// DiffDel checks for differences between the current and desired state for the
// user to be deleted indicated by the User fields.
func (u *User) DiffDel(status *resource.Status, userByName *user.User, nameNotFound bool) error {
if nameNotFound || userByName == nil {
return nil
}
switch {
case u.UID == "":
status.AddDifference("user", u.Username, fmt.Sprintf("<%s>", string(StateAbsent)), "")
case u.UID != "":
userByID, err := user.LookupId(u.UID)
_, uidNotFound := err.(user.UnknownUserIdError)
switch {
case uidNotFound:
status.RaiseLevel(resource.StatusCantChange)
return fmt.Errorf("uid %s does not exist", u.UID)
case userByID != nil && *userByID != *userByName:
status.RaiseLevel(resource.StatusCantChange)
return fmt.Errorf("uid %s belongs to different user", u.UID)
case userByID != nil && *userByID == *userByName:
status.AddDifference("user", u.Username, fmt.Sprintf("<%s>", string(StateAbsent)), "")
}
}
status.RaiseLevelForDiffs()
return nil
}
// DiffMod checks for differences between the user associated with u.Username
// and the desired modifications of that user indicated by the other User
// fields. The options to be used for the modify command are set.
func (u *User) DiffMod(status *resource.Status, currUser *user.User) (*ModUserOptions, error) {
options := new(ModUserOptions)
// Check for differences between currUser and the desired modifications
if u.NewUsername != "" {
usr, _ := user.Lookup(u.NewUsername)
if usr != nil {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("user %s already exists", u.NewUsername)
}
options.Username = u.NewUsername
status.AddDifference("username", u.Username, u.NewUsername, "")
}
if u.UID != "" {
usr, err := user.LookupId(u.UID)
_, uidNotFound := err.(user.UnknownUserIdError)
if uidNotFound {
options.UID = u.UID
status.AddDifference("uid", currUser.Uid, u.UID, "")
} else if usr != nil && currUser.Uid != u.UID {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("uid %s already exists", u.UID)
}
}
switch {
case u.GroupName != "":
grp, err := user.LookupGroup(u.GroupName)
if err != nil {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("group %s does not exist", u.GroupName)
} else if grp != nil && currUser.Gid != grp.Gid {
currGroup, err := user.LookupGroupId(currUser.Gid)
if err != nil {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("group gid %s does not exist", currUser.Gid)
}
options.Group = u.GroupName
status.AddDifference("group", currGroup.Name, u.GroupName, "")
}
case u.GID != "":
grp, err := user.LookupGroupId(u.GID)
if err != nil {
status.RaiseLevel(resource.StatusCantChange)
return nil, fmt.Errorf("group gid %s does not exist", u.GID)
} else if grp != nil && currUser.Gid != u.GID {
options.Group = u.GID
status.AddDifference("gid", currUser.Gid, u.GID, "")
}
}
if u.Name != "" {
if currUser.Name != u.Name {
options.Comment = u.Name
status.AddDifference("comment", currUser.Name, u.Name, "")
}
}
if u.HomeDir != "" {
if currUser.HomeDir != u.HomeDir {
options.Directory = u.HomeDir
status.AddDifference("home_dir name", currUser.HomeDir, u.HomeDir, "")
if u.MoveDir {
options.MoveDir = true
status.AddDifference("home_dir contents", currUser.HomeDir, u.HomeDir, "")
}
}
}
if u.Expiry != (time.Time{}) {
expiry, err := u.system.LookupUserExpiry(u.Username)
if err != nil {
return nil, fmt.Errorf("could not acquire current expiry for %s: %s", u.Username, err)
}
currentExpiry := expiry.Format(ShortForm)
newExpiry := u.Expiry.Format(ShortForm)
if currentExpiry != newExpiry {
if currentExpiry == MaxTime {
currentExpiry = "never"
}
options.Expiry = newExpiry
status.AddDifference("expiry", currentExpiry, options.Expiry, "")
}
}
status.RaiseLevelForDiffs()
return options, nil
}
// createHomeDiffs calls AddDifference for create_home and skel_dir after
// adding a user. The actual value of home_dir is accessed so the differences
// can be updated to no longer show <default home>.
func (u *User) createHomeDiffs(status *resource.Status) {
usr, _ := u.system.Lookup(u.Username)
if usr != nil && usr.HomeDir != "" {
status.AddDifference("create_home", fmt.Sprintf("<%s>", string(StateAbsent)), usr.HomeDir, "")
if u.SkelDir != "" {
status.AddDifference("skel_dir contents", u.SkelDir, usr.HomeDir, "")
}
}
}