Skip to content

Commit

Permalink
[command] Add a way to run commands as a different user without c…
Browse files Browse the repository at this point in the history
…hanging the command definition (#333)

<!--
Copyright (C) 2020-2022 Arm Limited or its affiliates and Contributors.
All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->
### Description

this is mostly for `posix` platforms where some commands need to be run
as `sudo` or as a different user.


### Test Coverage

<!--
Please put an `x` in the correct box e.g. `[x]` to indicate the testing
coverage of this change.
-->

- [x]  This change is covered by existing or additional automated tests.
- [ ] Manual testing has been performed (and evidence provided) as
automated testing was not feasible.
- [ ] Additional tests are not required for this change (e.g.
documentation update).
  • Loading branch information
acabarbaye authored Oct 13, 2023
1 parent 8332a4f commit 3aa6349
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 44 deletions.
1 change: 1 addition & 0 deletions changes/20231013112742.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: `[platform]` Add way to run commands as a user with privileges on posix systems
1 change: 1 addition & 0 deletions changes/20231013122936.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: `[command]` Add utilities to translate commands so that they are run as a separate user
25 changes: 25 additions & 0 deletions utils/platform/cmd_posix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//go:build linux || unix || (js && wasm) || darwin || aix || dragonfly || freebsd || nacl || netbsd || openbsd || solaris
// +build linux unix js,wasm darwin aix dragonfly freebsd nacl netbsd openbsd solaris

package platform

import "github.com/ARM-software/golang-utils/utils/subprocess/command"

var (
// sudoCommand describes the command to use to execute command as root
// when running in Docker, change to [gosu root](https://github.com/tianon/gosu)
sudoCommand = command.Sudo()
)

// DefineSudoCommand defines the command to run to be `root` or a user with enough privileges to manage accounts.
// e.g.
// - args="sudo" to run commands as `root`
// - args="su", "tom" if `tom` has enough privileges to run the command
// - args="gosu", "tom" if `tom` has enough privileges to run the command in a container and `gosu` is installed
func DefineSudoCommand(args ...string) {
sudoCommand = command.NewCommandAsDifferentUser(args...)
}

func defineCommandWithPrivileges(args ...string) (string, []string) {
return sudoCommand.RedefineCommand(args...)
}
32 changes: 2 additions & 30 deletions utils/platform/users_posix.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,6 @@ import (
"github.com/ARM-software/golang-utils/utils/commonerrors"
)

var (
// sudoCommand describes the command to use to execute command as root
// when running in Docker, change to [gosu root](https://github.com/tianon/gosu)
sudoCommand = []string{"sudo"}
)

// DefineSudoCommand defines the command to run to be `root` or a user with enough privileges to manage accounts.
func DefineSudoCommand(args ...string) {
sudoCommand = args
}

func addUser(ctx context.Context, username, fullname, password string) (err error) {
pwd := password
if pwd == "" {
Expand Down Expand Up @@ -69,28 +58,11 @@ func executeCommand(ctx context.Context, args ...string) error {
if len(args) == 0 {
return fmt.Errorf("%w: missing command to execute", commonerrors.ErrUndefined)
}
cmd := defineCommand(ctx, args...)
cmdName, cmdArgs := defineCommandWithPrivileges(args...)
cmd := exec.CommandContext(ctx, cmdName, cmdArgs...)
return runCommand(args[0], cmd)
}

func defineCommand(ctx context.Context, args ...string) *exec.Cmd {
var cmdName string
var cmdArgs []string
if len(sudoCommand) > 0 {
cmdName = sudoCommand[0]
for i := 1; i < len(sudoCommand); i++ {
cmdArgs = append(cmdArgs, sudoCommand[i])
}
cmdArgs = append(cmdArgs, args...)
} else {
cmdName = args[0]
for i := 1; i < len(args); i++ {
cmdArgs = append(cmdArgs, args[i])
}
}
return exec.CommandContext(ctx, cmdName, cmdArgs...)
}

func runCommand(cmdDescription string, cmd *exec.Cmd) error {
_, err := cmd.Output()
if err == nil {
Expand Down
87 changes: 87 additions & 0 deletions utils/subprocess/command/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package command

import "strings"

// CommandAsDifferentUser helps redefining commands so that they are run as a different user or with more privileges.
type CommandAsDifferentUser struct {
// changeUserCmd describes the command to use to execute any command as a different user
// e.g. it can be set as "sudo" to run commands as `root` or as "su","tom" or "gosu","jack"
changeUserCmd []string
}

// Redefine redefines a command so that it will be run as a different user.
func (c *CommandAsDifferentUser) Redefine(cmd string, args ...string) (cmdName string, cmdArgs []string) {
newArgs := []string{cmd}
newArgs = append(newArgs, args...)
cmdName, cmdArgs = c.RedefineCommand(newArgs...)
return
}

// RedefineCommand is the same as Redefine but with no separation between the command and its arguments (like the command in Docker)
func (c *CommandAsDifferentUser) RedefineCommand(args ...string) (cmdName string, cmdArgs []string) {
if len(c.changeUserCmd) > 0 {
cmdName = c.changeUserCmd[0]
for i := 1; i < len(c.changeUserCmd); i++ {
cmdArgs = append(cmdArgs, c.changeUserCmd[i])
}
cmdArgs = append(cmdArgs, args...)
} else {
cmdName = args[0]
for i := 1; i < len(args); i++ {
cmdArgs = append(cmdArgs, args[i])
}
}
return
}

// RedefineInShellForm returns the new command defined in shell form.
func (c *CommandAsDifferentUser) RedefineInShellForm(cmd string, args ...string) string {
ncmd, nargs := c.Redefine(cmd, args...)
return AsShellForm(ncmd, nargs...)
}

// NewCommandAsDifferentUser defines a command wrapper which helps redefining commands so that they are run as a different user.
// e.g.
// - switchUserCmd="sudo" to run commands as `root`
// - switchUserCmd="su", "tom" if `tom` has enough privileges to run the command
// - switchUserCmd="gosu", "tom" if `tom` has enough privileges to run the command in a container and `gosu` is installed
func NewCommandAsDifferentUser(switchUserCmd ...string) *CommandAsDifferentUser {
return &CommandAsDifferentUser{changeUserCmd: switchUserCmd}
}

// NewCommandAsRoot will create a command translator which will run command with `sudo`
func NewCommandAsRoot() *CommandAsDifferentUser {
return NewCommandAsDifferentUser("sudo")
}

// Sudo will call commands with `sudo`. Similar to NewCommandAsRoot
func Sudo() *CommandAsDifferentUser {
return NewCommandAsRoot()
}

// NewCommandInContainerAs will redefine commands to be run in containers as `username`. It will expect [gosu](https://github.com/tianon/gosu) to be installed and the user to have been defined.
func NewCommandInContainerAs(username string) *CommandAsDifferentUser {
return NewCommandAsDifferentUser("gosu", username)
}

// Gosu is similar to NewCommandInContainerAs.
func Gosu(username string) *CommandAsDifferentUser {
return NewCommandInContainerAs(username)
}

// Su will run commands as the user username using [su](https://www.unix.com/man-page/posix/1/su/)
func Su(username string) *CommandAsDifferentUser {
return NewCommandAsDifferentUser("su", username)
}

// Me will run the commands without switching user. It is a no operation wrapper.
func Me() *CommandAsDifferentUser {
return NewCommandAsDifferentUser()
}

// AsShellForm returns a command in its shell form.
func AsShellForm(cmd string, args ...string) string {
newCmd := []string{cmd}
newCmd = append(newCmd, args...)
return strings.Join(newCmd, " ")
}
20 changes: 20 additions & 0 deletions utils/subprocess/command/cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package command

import (
"fmt"
"testing"

"github.com/bxcodec/faker/v3"
"github.com/stretchr/testify/assert"
)

func TestCommandAsDifferentUser_Redefine(t *testing.T) {
assert.Equal(t, "sudo test 1 2 3", Sudo().RedefineInShellForm("test", "1", "2", "3"))
name := faker.Username()
assert.Equal(t, fmt.Sprintf("su %v test 1 2 3", name), Su(name).RedefineInShellForm("test", "1", "2", "3"))
name = faker.Username()
assert.Equal(t, fmt.Sprintf("gosu %v test 1 2 3", name), Gosu(name).RedefineInShellForm("test", "1", "2", "3"))
assert.Equal(t, "test 1 2 3", NewCommandAsDifferentUser().RedefineInShellForm("test", "1", "2", "3"))
assert.Equal(t, "test", Me().RedefineInShellForm("test"))
assert.Empty(t, Me().RedefineInShellForm(""))
}
12 changes: 10 additions & 2 deletions utils/subprocess/command_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/ARM-software/golang-utils/utils/commonerrors"
"github.com/ARM-software/golang-utils/utils/logs"
"github.com/ARM-software/golang-utils/utils/parallelisation"
commandUtils "github.com/ARM-software/golang-utils/utils/subprocess/command"
)

// INTERNAL
Expand Down Expand Up @@ -100,12 +101,14 @@ func (c *cmdWrapper) Pid() (pid int, err error) {
type command struct {
cmd string
args []string
as *commandUtils.CommandAsDifferentUser
loggers logs.Loggers
cmdWrapper cmdWrapper
}

func (c *command) createCommand(cmdCtx context.Context) *exec.Cmd {
cmd := exec.CommandContext(cmdCtx, c.cmd, c.args...) //nolint:gosec
newCmd, newArgs := c.as.Redefine(c.cmd, c.args...)
cmd := exec.CommandContext(cmdCtx, newCmd, newArgs...) //nolint:gosec
cmd.Stdout = newOutStreamer(c.loggers)
cmd.Stderr = newErrLogStreamer(c.loggers)
return cmd
Expand All @@ -129,18 +132,23 @@ func (c *command) Check() (err error) {
err = fmt.Errorf("missing command: %w", commonerrors.ErrUndefined)
return
}
if c.as == nil {
err = fmt.Errorf("missing command translator: %w", commonerrors.ErrUndefined)
return
}
if c.loggers == nil {
err = commonerrors.ErrNoLogger
return
}
return
}

func newCommand(loggers logs.Loggers, cmd string, args ...string) (osCmd *command) {
func newCommand(loggers logs.Loggers, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (osCmd *command) {
osCmd = &command{
cmd: cmd,
args: args,
loggers: loggers,
as: as,
cmdWrapper: cmdWrapper{},
}
return
Expand Down
9 changes: 5 additions & 4 deletions utils/subprocess/command_wrapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/ARM-software/golang-utils/utils/logs"
"github.com/ARM-software/golang-utils/utils/logs/logstest"
"github.com/ARM-software/golang-utils/utils/platform"
commandUtils "github.com/ARM-software/golang-utils/utils/subprocess/command"
)

func TestCmdRun(t *testing.T) {
Expand Down Expand Up @@ -54,9 +55,9 @@ func TestCmdRun(t *testing.T) {
loggers, err := logs.NewLogrLogger(logstest.NewTestLogger(t), "test")
require.NoError(t, err)
if platform.IsWindows() {
cmd = newCommand(loggers, test.cmdWindows, test.argWindows...)
cmd = newCommand(loggers, commandUtils.Me(), test.cmdWindows, test.argWindows...)
} else {
cmd = newCommand(loggers, test.cmdOther, test.argOther...)
cmd = newCommand(loggers, commandUtils.Me(), test.cmdOther, test.argOther...)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand Down Expand Up @@ -109,9 +110,9 @@ func TestCmdStartStop(t *testing.T) {
require.NoError(t, err)

if platform.IsWindows() {
cmd = newCommand(loggers, test.cmdWindows, test.argWindows...)
cmd = newCommand(loggers, commandUtils.Me(), test.cmdWindows, test.argWindows...)
} else {
cmd = newCommand(loggers, test.cmdOther, test.argOther...)
cmd = newCommand(loggers, commandUtils.Me(), test.cmdOther, test.argOther...)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand Down
47 changes: 39 additions & 8 deletions utils/subprocess/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/ARM-software/golang-utils/utils/commonerrors"
"github.com/ARM-software/golang-utils/utils/logs"
commandUtils "github.com/ARM-software/golang-utils/utils/subprocess/command"
)

// Subprocess describes what a subproccess is as well as any monitoring it may need.
Expand All @@ -28,14 +29,20 @@ type Subprocess struct {

// New creates a subprocess description.
func New(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (p *Subprocess, err error) {
p, err = newSubProcess(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Me(), cmd, args...)
return
}

// newSubProcess creates a subprocess description.
func newSubProcess(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (p *Subprocess, err error) {
p = new(Subprocess)
err = p.Setup(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...)
err = p.SetupAs(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
return
}

func newPlainSubProcess(ctx context.Context, loggers logs.Loggers, cmd string, args ...string) (p *Subprocess, err error) {
func newPlainSubProcess(ctx context.Context, loggers logs.Loggers, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (p *Subprocess, err error) {
p = new(Subprocess)
err = p.setup(ctx, loggers, false, "", "", "", cmd, args...)
err = p.setup(ctx, loggers, false, "", "", "", as, cmd, args...)
return
}

Expand All @@ -48,8 +55,27 @@ func Execute(ctx context.Context, loggers logs.Loggers, messageOnStart string, m
return p.Execute()
}

// ExecuteAs executes a command (i.e. spawns a subprocess) as a different user.
func ExecuteAs(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
p, err := newSubProcess(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
if err != nil {
return
}
return p.Execute()
}

// ExecuteWithSudo executes a command (i.e. spawns a subprocess) as root.
func ExecuteWithSudo(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) error {
return ExecuteAs(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Sudo(), cmd, args...)
}

// Output executes a command and returns its output (stdOutput and stdErr are merged) as string.
func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...string) (output string, err error) {
func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...string) (string, error) {
return OutputAs(ctx, loggers, commandUtils.Me(), cmd, args...)
}

// OutputAs executes a command as a different user and returns its output (stdOutput and stdErr are merged) as string.
func OutputAs(ctx context.Context, loggers logs.Loggers, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (output string, err error) {
if loggers == nil {
err = commonerrors.ErrNoLogger
return
Expand All @@ -63,7 +89,7 @@ func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...strin
if err != nil {
return
}
p, err := newPlainSubProcess(ctx, mLoggers, cmd, args...)
p, err := newPlainSubProcess(ctx, mLoggers, as, cmd, args...)
if err != nil {
return
}
Expand All @@ -74,11 +100,16 @@ func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...strin

// Setup sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure.
func (s *Subprocess) Setup(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) {
return s.setup(ctx, loggers, true, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...)
return s.setup(ctx, loggers, true, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Me(), cmd, args...)
}

// SetupAs is similar to Setup but allows the command to be run as a different user.
func (s *Subprocess) SetupAs(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
return s.setup(ctx, loggers, true, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
}

// Setup sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure.
func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, withAdditionalMessages bool, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) {
func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, withAdditionalMessages bool, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
if s.IsOn() {
err = s.Stop()
if err != nil {
Expand All @@ -89,7 +120,7 @@ func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, withAdditi
defer s.mu.Unlock()
s.isRunning.Store(false)
s.processMonitoring = newSubprocessMonitoring(ctx)
s.command = newCommand(loggers, cmd, args...)
s.command = newCommand(loggers, as, cmd, args...)
s.messsaging = newSubprocessMessaging(loggers, withAdditionalMessages, messageOnSuccess, messageOnFailure, messageOnStart, s.command.GetPath())
s.reset()
return s.check()
Expand Down

0 comments on commit 3aa6349

Please sign in to comment.