Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.17](backport #5988) Add support for pre existing Active Directory user #6201

Merged
merged 3 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading