Skip to content

Commit

Permalink
Add support for pre existing Active Directory user (#5988)
Browse files Browse the repository at this point in the history
* unpriviledged ad works'

* Ensure rights for AD users

* changelog

* support for mac and unix

* Update internal/pkg/agent/cmd/install.go

Co-authored-by: Blake Rouse <[email protected]>

* Update internal/pkg/agent/cmd/unprivileged.go

Co-authored-by: Blake Rouse <[email protected]>

* added e2e tests for darwin and linux

* reverted sample_test

* mage fmt

* do not require password

* more strict regex

* fix description in changelog

* resolved review comments

* linter

* handle g115 in user_windows.go

* more test coverage

* fix broken windows UT

* coverage

* fixed logic for UnprivilegedUser

* fixed windows tests

---------

Co-authored-by: Blake Rouse <[email protected]>
  • Loading branch information
michalpristas and blakerouse authored Dec 3, 2024
1 parent 8939f0f commit dccfb70
Show file tree
Hide file tree
Showing 21 changed files with 517 additions and 73 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Kind can be one of:
# - breaking-change: a change to previously-documented behavior
# - deprecation: functionality that is being removed in a later release
# - bug-fix: fixes a problem in a previous version
# - enhancement: extends functionality but does not break or fix existing behavior
# - feature: new functionality
# - known-issue: problems that we are aware of in a given version
# - security: impacts on the security of a product or a user’s deployment.
# - upgrade: important information for someone upgrading from a prior version
# - other: does not fit into any of the other categories
kind: feature

# Change summary; a 80ish characters long description of the change.
summary: Added support for pre-existing Active Directory user for unprivileged mode

# Long description; in case the summary is not enough to describe the change
# this field accommodate a description without length limits.
# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment.
description: User can specify custom pre-existing user for running unprivileged mode. This user will be given permissions to log on as a service.

# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
component:

# PR URL; optional; the PR number that added the changeset.
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
# Please provide it if you are adding a fragment for a different PR.
#pr: https://github.com/owner/repo/1234

# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
# If not present is automatically filled by the tooling with the issue linked to the PR number.
issue: https://github.com/elastic/elastic-agent/issues/4585
21 changes: 20 additions & 1 deletion internal/pkg/agent/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"

"github.com/spf13/cobra"

Expand All @@ -28,6 +29,10 @@ const (
flagInstallDevelopment = "develop"
flagInstallNamespace = "namespace"
flagInstallRunUninstallFromBinary = "run-uninstall-from-binary"

flagInstallCustomUser = "user"
flagInstallCustomGroup = "group"
flagInstallCustomPass = "password"
)

func newInstallCommandWithArgs(_ []string, streams *cli.IOStreams) *cobra.Command {
Expand Down Expand Up @@ -61,6 +66,13 @@ would like the Agent to operate.
cmd.Flags().Bool(flagInstallDevelopment, false, "Install into a standardized development namespace, may enable development specific options. Allows multiple Elastic Agents to be installed at once. (experimental)")
_ = cmd.Flags().MarkHidden(flagInstallDevelopment) // For internal use only.

// Active directory user specification
cmd.Flags().String(flagInstallCustomUser, "", "Custom user used to run Elastic Agent")
cmd.Flags().String(flagInstallCustomGroup, "", "Custom group used to access Elastic Agent files")
if runtime.GOOS == "windows" {
cmd.Flags().String(flagInstallCustomPass, "", "Password for user used to run Elastic Agent")
}

addEnrollFlags(cmd)

return cmd
Expand Down Expand Up @@ -249,7 +261,14 @@ func installCmd(streams *cli.IOStreams, cmd *cobra.Command) error {
progBar.Describe("Successfully uninstalled Elastic Agent")
}
if status != install.PackageInstall {
ownership, err = install.Install(cfgFile, topPath, unprivileged, log, progBar, streams)
customUser, _ := cmd.Flags().GetString(flagInstallCustomUser)
customGroup, _ := cmd.Flags().GetString(flagInstallCustomGroup)
customPass := ""
if runtime.GOOS == "windows" {
customPass, _ = cmd.Flags().GetString(flagInstallCustomPass)
}

ownership, err = install.Install(cfgFile, topPath, unprivileged, log, progBar, streams, customUser, customGroup, customPass)
if err != nil {
return fmt.Errorf("error installing package: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/pkg/agent/cmd/privileged.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func privilegedCmd(streams *cli.IOStreams, cmd *cobra.Command) (err error) {
}

pt := install.CreateAndStartNewSpinner(streams.Out, "Converting Elastic Agent to privileged...")
err = install.SwitchExecutingMode(topPath, pt, "", "")
err = install.SwitchExecutingMode(topPath, pt, "", "", "")
if err != nil {
// error already adds context
return err
Expand Down
20 changes: 19 additions & 1 deletion internal/pkg/agent/cmd/unprivileged.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"os"
"runtime"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -44,6 +45,13 @@ unprivileged it will still perform all the same work, including stopping and sta
cmd.Flags().BoolP("force", "f", false, "Do not prompt for confirmation")
cmd.Flags().DurationP("daemon-timeout", "", 0, "Timeout waiting for Elastic Agent daemon restart after the change is applied (-1 = no wait)")

// Custom user specification
cmd.Flags().String(flagInstallCustomUser, "", "Custom user used to run Elastic Agent")
cmd.Flags().String(flagInstallCustomGroup, "", "Custom group used to access Elastic Agent files")
if runtime.GOOS == "windows" {
cmd.Flags().String(flagInstallCustomPass, "", "Password for user used to run Elastic Agent")
}

return cmd
}

Expand All @@ -56,6 +64,13 @@ func unprivilegedCmd(streams *cli.IOStreams, cmd *cobra.Command) (err error) {
return fmt.Errorf("unable to perform unprivileged command, not executed with %s permissions", utils.PermissionUser)
}

customUser, _ := cmd.Flags().GetString(flagInstallCustomUser)
customGroup, _ := cmd.Flags().GetString(flagInstallCustomGroup)
customPass := ""
if runtime.GOOS == "windows" {
customPass, _ = cmd.Flags().GetString(flagInstallCustomPass)
}

// cannot switch to unprivileged when service components have issues
err = ensureNoServiceComponentIssues()
if err != nil {
Expand All @@ -77,7 +92,10 @@ func unprivilegedCmd(streams *cli.IOStreams, cmd *cobra.Command) (err error) {
}

pt := install.CreateAndStartNewSpinner(streams.Out, "Converting Elastic Agent to unprivileged...")
err = install.SwitchExecutingMode(topPath, pt, install.ElasticUsername, install.ElasticGroupName)

username, password := install.UnprivilegedUser(customUser, customPass)
groupName := install.UnprivilegedGroup(customGroup)
err = install.SwitchExecutingMode(topPath, pt, username, groupName, password)
if err != nil {
// error already adds context
return err
Expand Down
33 changes: 26 additions & 7 deletions internal/pkg/agent/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const (
)

// Install installs Elastic Agent persistently on the system including creating and starting its service.
func Install(cfgFile, topPath string, unprivileged bool, log *logp.Logger, pt *progressbar.ProgressBar, streams *cli.IOStreams) (utils.FileOwner, error) {
func Install(cfgFile, topPath string, unprivileged bool, log *logp.Logger, pt *progressbar.ProgressBar, streams *cli.IOStreams, customUser, customGroup, userPassword string) (utils.FileOwner, error) {
dir, err := findDirectory()
if err != nil {
return utils.FileOwner{}, errors.New(err, "failed to discover the source directory for installation", errors.TypeFilesystem)
Expand All @@ -49,13 +49,15 @@ func Install(cfgFile, topPath string, unprivileged bool, log *logp.Logger, pt *p
var ownership utils.FileOwner
username := ""
groupName := ""
password := ""
if unprivileged {
username = ElasticUsername
groupName = ElasticGroupName
ownership, err = EnsureUserAndGroup(username, groupName, pt)
username, password = UnprivilegedUser(customUser, userPassword)
groupName = UnprivilegedGroup(customGroup)
ownership, err = EnsureUserAndGroup(username, groupName, pt, username == ElasticUsername && password == "") // force create only elastic user
if err != nil {
// error context already added by EnsureUserAndGroup
return utils.FileOwner{}, err

}
}

Expand Down Expand Up @@ -147,7 +149,7 @@ func Install(cfgFile, topPath string, unprivileged bool, log *logp.Logger, pt *p

// install service
pt.Describe("Installing service")
err = InstallService(topPath, ownership, username, groupName)
err = InstallService(topPath, ownership, username, groupName, password)
if err != nil {
pt.Describe("Failed to install service")
// error context already added by InstallService
Expand Down Expand Up @@ -371,11 +373,12 @@ func StatusService(topPath string) (service.Status, error) {
}

// InstallService installs the service.
func InstallService(topPath string, ownership utils.FileOwner, username string, groupName string) error {
opts, err := withServiceOptions(username, groupName)
func InstallService(topPath string, ownership utils.FileOwner, username string, groupName string, password string) error {
opts, err := withServiceOptions(username, groupName, password)
if err != nil {
return fmt.Errorf("error getting service installation options: %w", err)
}

svc, err := newService(topPath, opts...)
if err != nil {
return fmt.Errorf("error creating new service handler for install: %w", err)
Expand Down Expand Up @@ -482,3 +485,19 @@ func CreateInstallMarker(topPath string, ownership utils.FileOwner) error {
_ = handle.Close()
return fixInstallMarkerPermissions(markerFilePath, ownership)
}

func UnprivilegedUser(username, password string) (string, string) {
if username != "" {
return username, password
}

return ElasticUsername, password
}

func UnprivilegedGroup(groupName string) string {
if groupName != "" {
return groupName
}

return ElasticGroupName
}
45 changes: 45 additions & 0 deletions internal/pkg/agent/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package install

import (
"fmt"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -65,6 +66,50 @@ func TestHasAllSSDs(t *testing.T) {
}
}

func TestUnprivilegedUser(t *testing.T) {
testCases := []struct {
username string
password string
expectedUsername string
expectedPassword string
}{
{"", "", ElasticUsername, ""},
{"", "pass", ElasticUsername, "pass"},
{"user", "", "user", ""},
{"user", "pass", "user", "pass"},
}
for i, tc := range testCases {
t.Run(
fmt.Sprintf("test case #%d: %s:%s", i, tc.username, tc.password),
func(t *testing.T) {
username, password := UnprivilegedUser(tc.username, tc.password)
assert.Equal(t, tc.expectedUsername, username)
assert.Equal(t, password, tc.expectedPassword)
},
)
}
}

func TestUnprivilegedGroup(t *testing.T) {
testCases := []struct {
groupName string
expectedGroupName string
}{
{"", ElasticGroupName},
{"custom", "custom"},
}

for i, tc := range testCases {
t.Run(
fmt.Sprintf("test case #%d: %s", i, tc.groupName),
func(t *testing.T) {
groupname := UnprivilegedGroup(tc.groupName)
assert.Equal(t, tc.expectedGroupName, groupname)
},
)
}
}

var sampleManifestContent = `
version: co.elastic.agent/v1
kind: PackageManifest
Expand Down
2 changes: 1 addition & 1 deletion internal/pkg/agent/install/install_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func fixInstallMarkerPermissions(markerFilePath string, ownership utils.FileOwne
}

// withServiceOptions just sets the user/group for the service.
func withServiceOptions(username string, groupName string) ([]serviceOpt, error) {
func withServiceOptions(username string, groupName string, _ string) ([]serviceOpt, error) {
return []serviceOpt{withUserGroup(username, groupName)}, nil
}

Expand Down
37 changes: 35 additions & 2 deletions internal/pkg/agent/install/install_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

"golang.org/x/sys/windows"
Expand All @@ -21,6 +22,13 @@ import (
"github.com/elastic/elastic-agent/version"
)

const (
// Conforming to https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/naming-conventions-for-computer-domain-site-ou#domain-names
// domain names can contain all alphanumeric characters except for the extended characters that appear in the Disallowed characters list. Names can contain a period, but names can't start with a period.
// Disallowed characters: [, ~ : @ # $ % ^ ' . ( ) { } _ {whitespace} \ / ]
activeDirectoryUsername = `^[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)*\\[A-Za-z0-9.-]{1,104}$`
)

// postInstall performs post installation for Windows systems.
func postInstall(topPath string) error {
// delete the top-level elastic-agent.exe
Expand Down Expand Up @@ -55,13 +63,24 @@ func fixInstallMarkerPermissions(markerFilePath string, ownership utils.FileOwne
}

// withServiceOptions just sets the user/group for the service.
func withServiceOptions(username string, groupName string) ([]serviceOpt, error) {
func withServiceOptions(username string, groupName string, password string) ([]serviceOpt, error) {
if username == "" {
// not installed with --unprivileged; nothing to do
return []serviceOpt{}, nil
}

// service requires a password to launch as the user
if password != "" {
if isFullDomainName, err := isWindowsDomainUsername(username); err != nil {
return nil, fmt.Errorf("failed to parse username: %w", err)
} else if !isFullDomainName {
return nil, fmt.Errorf(`username is not in proper format 'domain\username', contains illegal character: ,~:@#$%%^'.(){}_\/ or a whitespace`)
}

// existing user
return []serviceOpt{withUserGroup(username, groupName), withPassword(password)}, nil
}

// service requires a password to launch as the use
// this sets it to a random password that is only known by the service
password, err := RandomPassword()
if err != nil {
Expand Down Expand Up @@ -109,3 +128,17 @@ func serviceConfigure(ownership utils.FileOwner) error {
}
return nil
}

func isWindowsDomainUsername(username string) (bool, error) {
if !strings.Contains(username, `\`) {
// fail fast
return false, nil
}

match, err := regexp.MatchString(activeDirectoryUsername, username)
if err != nil {
return false, err
}

return match, nil
}
Loading

0 comments on commit dccfb70

Please sign in to comment.