Allow host users to be created with a specific UID or GID (#29305)

* Allow setting HostUserUID/GID traits

* Create users with specified UID/GID

* rename the traits, fix typo

* Document host user creation with specific UID/GID

* resolve comments

* Resolve comments

* Update doc and help strings
This commit is contained in:
Alex McGrath 2023-08-02 15:14:42 +01:00 committed by GitHub
parent 9fc2679ea2
commit 2fbe27b7e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 220 additions and 27 deletions

View file

@ -384,6 +384,14 @@ const (
// TraitGCPServiceAccounts is the name of the role variable used to store
// allowed GCP service accounts.
TraitGCPServiceAccounts = "gcp_service_accounts"
// TraitHostUserUID is the name of the variable used to specify
// the UID to create host user account with.
TraitHostUserUID = "host_user_uid"
// TraitHostUserGID is the name of the variable used to specify
// the GID to create host user account with.
TraitHostUserGID = "host_user_gid"
)
const (

View file

@ -107,6 +107,10 @@ type User interface {
SetAzureIdentities(azureIdentities []string)
// SetGCPServiceAccounts sets a list of GCP service accounts for the user
SetGCPServiceAccounts(accounts []string)
// SetHostUserUID sets the UID for host users
SetHostUserUID(uid string)
// SetHostUserGID sets the GID for host users
SetHostUserGID(gid string)
// GetCreatedBy returns information about user
GetCreatedBy() CreatedBy
// SetCreatedBy sets created by information
@ -340,6 +344,16 @@ func (u *UserV2) SetGCPServiceAccounts(accounts []string) {
u.setTrait(constants.TraitGCPServiceAccounts, accounts)
}
// SetHostUserUID sets the host user UID
func (u *UserV2) SetHostUserUID(uid string) {
u.setTrait(constants.TraitHostUserUID, []string{uid})
}
// SetHostUserGID sets the host user GID
func (u *UserV2) SetHostUserGID(uid string) {
u.setTrait(constants.TraitHostUserGID, []string{uid})
}
// GetStatus returns login status of the user
func (u *UserV2) GetStatus() LoginStatus {
return u.Spec.Status

View file

@ -1699,6 +1699,8 @@ $ tctl users add [<flags>] <account>
| `--gcp-service-accounts` | none | Comma-separated strings | List of allowed GCP service accounts for the new user |
| `--azure-identities` | none | Comma-separated strings | List of Azure managed identities to allow the user to assume. Must be the full URIs of the identities |
| `--ttl` | 1h | relative duration like 5s, 2m, or 3h, **maximum 48h** | Set expiration time for token |
| `--host-user-uid` | none | Unix UID | UID for auto provisioned host users to use |
| `--host-user-gid` | none | Unix GID | GID for auto provisioned host users to use |
#### Global flags

View file

@ -26,7 +26,7 @@ since it must execute these commands in order to create transient users:
- `visudo`
- (!docs/pages/includes/tctl.mdx!)
## Step 1/2. Configure a role
## Step 1/3. Configure a role
First, create a role with `create_host_user_mode` set to `drop` or `keep`.
@ -132,7 +132,43 @@ ssh_service:
(!docs/pages/includes/add-role-to-user.mdx role="auto-users"!)
## Step 2/2 Test host user creation
## Step 2/3. [Optional] Configure the UID and GID for the created users
If the user has the `host_user_uid` and `host_user_gid` traits
specified, when the host user is being created the UID and GID will be
set to those values.
These values can either be set manually when creating or updating the
user through `tctl`, or it can be set via SSO attributes of the same
name.
If a group with the specified GID does not already exist, a group will
be created with the same login name as the user being created.
```yaml
kind: user
metadata:
name: some_teleport_user
spec:
...
traits:
logins:
- root
- alex
host_user_gid:
# gid and uid values must be quoted.
- "1234"
host_user_uid:
- "5678"
```
<Admonition type="warning">
If multiple entries are specified in the `host_user_uid` or `host_user_gid` only the first entry will be used.
</Admonition>
## Step 3/3 Test host user creation
When you connect to a remote Node via `tsh`, and host user creation is enabled, the
Teleport SSH Service will automatically create a user on the host:

View file

@ -66,14 +66,14 @@ func TestRootHostUsersBackend(t *testing.T) {
})
t.Run("Test CreateGroup", func(t *testing.T) {
err := backend.CreateGroup(testgroup)
err := backend.CreateGroup(testgroup, "")
require.NoError(t, err)
err = backend.CreateGroup(testgroup)
err = backend.CreateGroup(testgroup, "")
require.True(t, trace.IsAlreadyExists(err))
})
t.Run("Test CreateUser and group", func(t *testing.T) {
err := backend.CreateUser(testuser, []string{testgroup})
err := backend.CreateUser(testuser, []string{testgroup}, "", "")
require.NoError(t, err)
tuser, err := backend.Lookup(testuser)
@ -86,7 +86,7 @@ func TestRootHostUsersBackend(t *testing.T) {
require.NoError(t, err)
require.Contains(t, tuserGids, group.Gid)
err = backend.CreateUser(testuser, []string{})
err = backend.CreateUser(testuser, []string{}, "", "")
require.True(t, trace.IsAlreadyExists(err))
})
@ -107,7 +107,7 @@ func TestRootHostUsersBackend(t *testing.T) {
}
})
for _, u := range checkUsers {
err := backend.CreateUser(u, []string{})
err := backend.CreateUser(u, []string{}, "", "")
require.NoError(t, err)
}
@ -190,6 +190,39 @@ func TestRootHostUsers(t *testing.T) {
require.Equal(t, err, user.UnknownUserError(testuser))
})
t.Run("test create user with uid and gid", func(t *testing.T) {
users := srv.NewHostUsers(context.Background(), presence, "host_uuid")
testUID := "1234"
testGID := "1337"
_, err := user.LookupGroupId(testGID)
require.ErrorIs(t, err, user.UnknownGroupIdError(testGID))
closer, err := users.CreateUser(testuser, &services.HostUsersInfo{
Mode: types.CreateHostUserMode_HOST_USER_MODE_DROP,
UID: testUID,
GID: testGID,
})
require.NoError(t, err)
t.Cleanup(cleanupUsersAndGroups([]string{testuser}, []string{types.TeleportServiceGroup}))
group, err := user.LookupGroupId(testGID)
require.NoError(t, err)
require.Equal(t, testuser, group.Name)
u, err := user.Lookup(testuser)
require.NoError(t, err)
require.Equal(t, u.Uid, testUID)
require.Equal(t, u.Gid, testGID)
require.NoError(t, closer.Close())
_, err = user.Lookup(testuser)
require.Equal(t, err, user.UnknownUserError(testuser))
})
t.Run("test create sudoers enabled users", func(t *testing.T) {
if _, err := exec.LookPath("visudo"); err != nil {
t.Skip("Visudo not found on path")

View file

@ -838,6 +838,10 @@ type HostUsersInfo struct {
// Mode determines if a host user should be deleted after a session
// ends or not.
Mode types.CreateHostUserMode
// UID is the UID that the host user will be created with
UID string
// GID is the GID that the host user will be created with
GID string
}
// HostUsers returns host user information matching a server or nil if
@ -927,10 +931,24 @@ func (a *accessChecker) HostUsers(s types.Server) (*HostUsersInfo, error) {
sudoers = finalSudoers
}
traits := a.Traits()
var gid string
gidL := traits[constants.TraitHostUserGID]
if len(gidL) >= 1 {
gid = gidL[0]
}
var uid string
uidL := traits[constants.TraitHostUserUID]
if len(uidL) >= 1 {
uid = uidL[0]
}
return &HostUsersInfo{
Groups: utils.StringsSliceFromSet(groups),
Sudoers: sudoers,
Mode: mode,
UID: uid,
GID: gid,
}, nil
}

View file

@ -64,10 +64,12 @@ type HostUsersBackend interface {
Lookup(name string) (*user.User, error)
// LookupGroup retrieves a group by name.
LookupGroup(group string) (*user.Group, error)
// LookupGroupByID retrieves a group by its ID.
LookupGroupByID(gid string) (*user.Group, error)
// CreateGroup creates a group on a host.
CreateGroup(group string) error
CreateGroup(group string, gid string) error
// CreateUser creates a user on a host.
CreateUser(name string, groups []string) error
CreateUser(name string, groups []string, uid, gid string) error
// DeleteUser deletes a user from a host.
DeleteUser(name string) error
// CheckSudoers ensures that a sudoers file to be written is valid
@ -224,8 +226,15 @@ func (u *HostUserManagement) CreateUser(name string, ui *services.HostUsersInfo)
if err := u.storage.UpsertHostUserInteractionTime(u.ctx, name, time.Now()); err != nil {
return trace.Wrap(err)
}
if ui.GID != "" {
// if gid is specified a group must already exist
err := u.backend.CreateGroup(name, ui.GID)
if err != nil && !trace.IsAlreadyExists(err) {
return trace.Wrap(err)
}
}
err = u.backend.CreateUser(name, groups)
err = u.backend.CreateUser(name, groups, ui.UID, ui.GID)
if err != nil && !trace.IsAlreadyExists(err) {
return trace.WrapWithMessage(err, "error while creating user")
}
@ -288,7 +297,7 @@ func (u *HostUserManagement) createGroupIfNotExist(group string) error {
if err != nil && !isUnknownGroupError(err, group) {
return trace.Wrap(err)
}
err = u.backend.CreateGroup(group)
err = u.backend.CreateGroup(group, "")
if trace.IsAlreadyExists(err) {
return nil
}
@ -303,6 +312,7 @@ func (u *HostUserManagement) createGroupIfNotExist(group string) error {
// See github issue - https://github.com/golang/go/issues/40334
func isUnknownGroupError(err error, groupName string) bool {
return errors.Is(err, user.UnknownGroupError(groupName)) ||
errors.Is(err, user.UnknownGroupIdError(groupName)) ||
strings.HasSuffix(err.Error(), syscall.ENOENT.Error()) ||
strings.HasSuffix(err.Error(), syscall.ESRCH.Error())
}

View file

@ -59,6 +59,11 @@ func (*HostUsersProvisioningBackend) LookupGroup(name string) (*user.Group, erro
return user.LookupGroup(name)
}
// LookupGroup host group information lookup by GID
func (*HostUsersProvisioningBackend) LookupGroupByID(gid string) (*user.Group, error) {
return user.LookupGroupId(gid)
}
// GetAllUsers returns a full list of users present on a system
func (*HostUsersProvisioningBackend) GetAllUsers() ([]string, error) {
users, _, err := host.GetAllUsers()
@ -66,14 +71,14 @@ func (*HostUsersProvisioningBackend) GetAllUsers() ([]string, error) {
}
// CreateGroup creates a group on a host
func (*HostUsersProvisioningBackend) CreateGroup(name string) error {
_, err := host.GroupAdd(name)
func (*HostUsersProvisioningBackend) CreateGroup(name string, gid string) error {
_, err := host.GroupAdd(name, gid)
return trace.Wrap(err)
}
// CreateUser creates a user on a host
func (*HostUsersProvisioningBackend) CreateUser(name string, groups []string) error {
_, err := host.UserAdd(name, groups)
func (*HostUsersProvisioningBackend) CreateUser(name string, groups []string, uid, gid string) error {
_, err := host.UserAdd(name, groups, uid, gid)
return trace.Wrap(err)
}

View file

@ -40,6 +40,10 @@ type testHostUserBackend struct {
groups map[string]string
// sudoers: user -> entries
sudoers map[string]string
// userUID: user -> uid
userUID map[string]string
// userGID: user -> gid
userGID map[string]string
}
func newTestUserMgmt() *testHostUserBackend {
@ -47,6 +51,8 @@ func newTestUserMgmt() *testHostUserBackend {
users: map[string][]string{},
groups: map[string]string{},
sudoers: map[string]string{},
userUID: map[string]string{},
userGID: map[string]string{},
}
}
@ -74,6 +80,13 @@ func (tm *testHostUserBackend) LookupGroup(groupname string) (*user.Group, error
}, nil
}
func (tm *testHostUserBackend) LookupGroupByID(gid string) (*user.Group, error) {
return &user.Group{
Gid: tm.groups[gid],
Name: gid,
}, nil
}
func (tm *testHostUserBackend) UserGIDs(u *user.User) ([]string, error) {
ids := make([]string, 0, len(tm.users[u.Username]))
for _, id := range tm.users[u.Username] {
@ -82,7 +95,7 @@ func (tm *testHostUserBackend) UserGIDs(u *user.User) ([]string, error) {
return ids, nil
}
func (tm *testHostUserBackend) CreateGroup(group string) error {
func (tm *testHostUserBackend) CreateGroup(group, gid string) error {
_, ok := tm.groups[group]
if ok {
return trace.AlreadyExists("Group %q, already exists", group)
@ -91,12 +104,14 @@ func (tm *testHostUserBackend) CreateGroup(group string) error {
return nil
}
func (tm *testHostUserBackend) CreateUser(user string, groups []string) error {
func (tm *testHostUserBackend) CreateUser(user string, groups []string, uid, gid string) error {
_, ok := tm.users[user]
if ok {
return trace.AlreadyExists("Group %q, already exists", user)
}
tm.users[user] = groups
tm.userUID[user] = uid
tm.userGID[user] = gid
return nil
}
@ -167,8 +182,8 @@ func TestUserMgmt_CreateTemporaryUser(t *testing.T) {
require.NoError(t, closer.Close())
require.NotContains(t, backend.users, "bob")
backend.CreateGroup("testgroup")
backend.CreateUser("simon", []string{})
backend.CreateGroup("testgroup", "")
backend.CreateUser("simon", []string{}, "", "")
// try to create a temporary user for simon
closer, err = users.CreateUser("simon", userinfo)
@ -215,12 +230,12 @@ func TestUserMgmtSudoers_CreateTemporaryUser(t *testing.T) {
}
// test user already exists but teleport-service group has not yet
// been created
backend.CreateUser("testuser", nil)
backend.CreateUser("testuser", nil, "", "")
_, err := users.CreateUser("testuser", &services.HostUsersInfo{
Mode: types.CreateHostUserMode_HOST_USER_MODE_DROP,
})
require.True(t, trace.IsAlreadyExists(err))
backend.CreateGroup(types.TeleportServiceGroup)
backend.CreateGroup(types.TeleportServiceGroup, "")
// IsAlreadyExists error when teleport-service group now exists
_, err = users.CreateUser("testuser", &services.HostUsersInfo{
Mode: types.CreateHostUserMode_HOST_USER_MODE_DROP,
@ -258,12 +273,12 @@ func TestUserMgmt_DeleteAllTeleportSystemUsers(t *testing.T) {
for _, user := range usersDB {
for _, group := range user.groups {
mgmt.CreateGroup(group)
mgmt.CreateGroup(group, "")
}
if slices.Contains(user.groups, types.TeleportServiceGroup) {
users.CreateUser(user.user, &services.HostUsersInfo{Groups: user.groups})
} else {
mgmt.CreateUser(user.user, user.groups)
mgmt.CreateUser(user.user, user.groups, "", "")
}
}
require.NoError(t, users.DeleteAllUsers())

View file

@ -33,13 +33,20 @@ const GroupExistExit = 9
const UserExistExit = 9
const UserLoggedInExit = 8
// GroupAdd creates a group on a host using `groupadd`
func GroupAdd(groupname string) (exitCode int, err error) {
// GroupAdd creates a group on a host using `groupadd` optionally
// specifying the GID to create the group with.
func GroupAdd(groupname string, gid string) (exitCode int, err error) {
groupaddBin, err := exec.LookPath("groupadd")
if err != nil {
return -1, trace.Wrap(err, "cant find groupadd binary")
}
cmd := exec.Command(groupaddBin, groupname)
var args []string
if gid != "" {
args = append(args, "--gid", gid)
}
args = append(args, groupname)
cmd := exec.Command(groupaddBin, args...)
output, err := cmd.CombinedOutput()
log.Debugf("%s output: %s", cmd.Path, string(output))
if cmd.ProcessState.ExitCode() == GroupExistExit {
@ -49,7 +56,7 @@ func GroupAdd(groupname string) (exitCode int, err error) {
}
// UserAdd creates a user on a host using `useradd`
func UserAdd(username string, groups []string) (exitCode int, err error) {
func UserAdd(username string, groups []string, uid, gid string) (exitCode int, err error) {
useraddBin, err := exec.LookPath("useradd")
if err != nil {
return -1, trace.Wrap(err, "cant find useradd binary")
@ -59,6 +66,13 @@ func UserAdd(username string, groups []string) (exitCode int, err error) {
if len(groups) != 0 {
args = append(args, "--groups", strings.Join(groups, ","))
}
if uid != "" {
args = append(args, "--uid", uid)
}
if gid != "" {
args = append(args, "--gid", gid)
}
cmd := exec.Command(useraddBin, args...)
output, err := cmd.CombinedOutput()
log.Debugf("%s output: %s", cmd.Path, string(output))

View file

@ -21,6 +21,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
@ -55,6 +56,10 @@ type UserCommand struct {
allowedAzureIdentities []string
allowedGCPServiceAccounts []string
allowedRoles []string
hostUserUID string
hostUserUIDProvided bool
hostUserGID string
hostUserGIDProvided bool
ttl time.Duration
@ -88,6 +93,8 @@ func (u *UserCommand) Initialize(app *kingpin.Application, config *servicecfg.Co
u.userAdd.Flag("aws-role-arns", "List of allowed AWS role ARNs for the new user").StringsVar(&u.allowedAWSRoleARNs)
u.userAdd.Flag("azure-identities", "List of allowed Azure identities for the new user").StringsVar(&u.allowedAzureIdentities)
u.userAdd.Flag("gcp-service-accounts", "List of allowed GCP service accounts for the new user").StringsVar(&u.allowedGCPServiceAccounts)
u.userAdd.Flag("host-user-uid", "UID for auto provisioned host users to use").IsSetByUser(&u.hostUserUIDProvided).StringVar(&u.hostUserUID)
u.userAdd.Flag("host-user-gid", "GID for auto provisioned host users to use").IsSetByUser(&u.hostUserGIDProvided).StringVar(&u.hostUserGID)
u.userAdd.Flag("roles", "List of roles for the new user to assume").Required().StringsVar(&u.allowedRoles)
@ -121,6 +128,8 @@ func (u *UserCommand) Initialize(app *kingpin.Application, config *servicecfg.Co
StringsVar(&u.allowedAzureIdentities)
u.userUpdate.Flag("set-gcp-service-accounts", "List of allowed GCP service accounts for the user, replaces current service accounts").
StringsVar(&u.allowedGCPServiceAccounts)
u.userUpdate.Flag("set-host-user-uid", "UID for auto provisioned host users to use. Value can be reset by providing an empty string").IsSetByUser(&u.hostUserUIDProvided).StringVar(&u.hostUserUID)
u.userUpdate.Flag("set-host-user-gid", "GID for auto provisioned host users to use. Value can be reset by providing an empty string").IsSetByUser(&u.hostUserGIDProvided).StringVar(&u.hostUserGID)
u.userList = users.Command("ls", "Lists all user accounts.")
u.userList.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).StringVar(&u.format)
@ -250,6 +259,17 @@ func (u *UserCommand) Add(ctx context.Context, client auth.ClientI) error {
}
}
if u.hostUserUIDProvided && u.hostUserUID != "" {
if _, err := strconv.Atoi(u.hostUserUID); err != nil {
return trace.BadParameter("host user UID must be a numeric ID")
}
}
if u.hostUserGIDProvided && u.hostUserGID != "" {
if _, err := strconv.Atoi(u.hostUserGID); err != nil {
return trace.BadParameter("host user GID must be a numeric ID")
}
}
traits := map[string][]string{
constants.TraitLogins: u.allowedLogins,
constants.TraitWindowsLogins: u.allowedWindowsLogins,
@ -261,6 +281,8 @@ func (u *UserCommand) Add(ctx context.Context, client auth.ClientI) error {
constants.TraitAWSRoleARNs: flattenSlice(u.allowedAWSRoleARNs),
constants.TraitAzureIdentities: azureIdentities,
constants.TraitGCPServiceAccounts: gcpServiceAccounts,
constants.TraitHostUserUID: {u.hostUserUID},
constants.TraitHostUserGID: {u.hostUserGID},
}
user, err := types.NewUser(u.login)
@ -416,6 +438,22 @@ func (u *UserCommand) Update(ctx context.Context, client auth.ClientI) error {
updateMessages["GCP service accounts"] = accounts
}
if u.hostUserUIDProvided && u.hostUserUID != "" {
if _, err := strconv.Atoi(u.hostUserUID); err != nil {
return trace.BadParameter("host user UID must be a numeric ID")
}
user.SetHostUserUID(u.hostUserUID)
updateMessages["Host user UID"] = []string{u.hostUserUID}
}
if u.hostUserGIDProvided && u.hostUserGID != "" {
if _, err := strconv.Atoi(u.hostUserGID); err != nil {
return trace.BadParameter("host user GID must be a numeric ID")
}
user.SetHostUserGID(u.hostUserGID)
updateMessages["Host user GID"] = []string{u.hostUserGID}
}
if len(updateMessages) == 0 {
return trace.BadParameter("Nothing to update. Please provide at least one --set flag.")
}