asteris-llc/converge

View on GitHub
resource/user/user.go

Summary

Maintainability
D
2 days
Test Coverage
// 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, "")
        }
    }
}