Skip to content

Commit 2f6566a

Browse files
mergify[bot]Kaan Yalti
andauthored
fix(7794): update Windows re-enrollment logic and rename test (#8503) (#8570)
* fix(7794): update Windows re-enrollment logic and rename test - Updated enroll.go to add a Windows-specific condition for root/Administrator privilege checks. - Removed Windows-specific file owner check implementation and its test. - Renamed integration test from enroll_unprivileged_test.go to re-enroll_test.go to better reflect its purpose. * fix(7794): implement no-op ownership check for Windows - Adds a Windows-specific implementation of isOwnerExec that always returns true (no-op), addressing build and re-enrollment issues. - Retains platform-specific files for clarity and maintainability. - Adds a Windows-specific test to ensure code coverage is maintained. - Removes the Windows OS check from enroll.go, relying on the no-op for Windows. * changelog: add fragment for Windows re-enrollment bugfix * fix(7794): remove unnecessary build tag * fix(7794): enhance re-enrollment tests with structured test cases - Renamed the test function to TestRenEnroll for clarity. - Introduced structured test cases for unprivileged and privileged agent re-enrollment scenarios. - Removed Windows-specific checks and consolidated assertions into the test cases. - Updated the test logic to improve readability and maintainability. * fix(7794): enhance re-enrollment test structure and OS handling * fix(7794): reverted to individual test functions because ci enforces where in the test function define is called * fix(7794): removed unnecessary var definition * fix(7794): fix bool value for privileged test case * fix(7794): added status check after second enroll, moved file owner error to enroll.go * fix(7794): remove unenroll step from integration tests * fix(7794): refactored test code * fix(7794): added comment explaining why windows is excluded from unprivileged test case (cherry picked from commit 00f1662) Co-authored-by: Kaan Yalti <[email protected]>
1 parent 67165da commit 2f6566a

File tree

7 files changed

+163
-183
lines changed

7 files changed

+163
-183
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Kind can be one of:
2+
# - breaking-change: a change to previously-documented behavior
3+
# - deprecation: functionality that is being removed in a later release
4+
# - bug-fix: fixes a problem in a previous version
5+
# - enhancement: extends functionality but does not break or fix existing behavior
6+
# - feature: new functionality
7+
# - known-issue: problems that we are aware of in a given version
8+
# - security: impacts on the security of a product or a user’s deployment.
9+
# - upgrade: important information for someone upgrading from a prior version
10+
# - other: does not fit into any of the other categories
11+
kind: bug-fix
12+
13+
# Change summary; a 80ish characters long description of the change.
14+
summary: relax file ownership check to allow admin re-enrollment on Windows
15+
16+
# Long description; in case the summary is not enough to describe the change
17+
# this field accommodate a description without length limits.
18+
# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment.
19+
description: |
20+
On Windows, the agent previously enforced a strict file ownership (SID) check during re-enrollment, which prevented legitimate admin users from re-enrolling the agent if the owner did not match. This PR changes the Windows-specific logic to a no-op, allowing any admin to re-enroll the agent. This restores usability for admin users, but reintroduces the risk that privileged re-enrollment can break unprivileged installs. The Unix-specific ownership check remains unchanged.
21+
22+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
23+
component: elastic-agent
24+
25+
# PR URL; optional; the PR number that added the changeset.
26+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
27+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
28+
# Please provide it if you are adding a fragment for a different PR.
29+
pr: https://github.com/elastic/elastic-agent/pull/8503
30+
31+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
32+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
33+
issue: https://github.com/elastic/elastic-agent/issues/7794

internal/pkg/agent/cmd/enroll.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import (
2929
"github.com/elastic/elastic-agent/pkg/utils"
3030
)
3131

32+
var UserOwnerMismatchError = errors.New("the command is executed as root but the program files are not owned by the root user. execute the command as the user that owns the program files")
33+
3234
const (
3335
fromInstallArg = "from-install"
3436
fromInstallUserArg = "from-install-user"

internal/pkg/agent/cmd/enroll_match_fileowner_unix.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,8 @@ import (
1111
"os"
1212
"strconv"
1313
"syscall"
14-
15-
"github.com/elastic/elastic-agent/internal/pkg/agent/errors"
1614
)
1715

18-
var UserOwnerMismatchError = errors.New("the command is executed as root but the program files are not owned by the root user. execute the command as the user that owns the program files")
19-
2016
func getFileOwner(filePath string) (string, error) {
2117
fileInfo, err := os.Stat(filePath)
2218
if err != nil {

internal/pkg/agent/cmd/enroll_match_fileowner_windows.go

Lines changed: 3 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -6,87 +6,7 @@
66

77
package cmd
88

9-
import (
10-
"fmt"
11-
12-
"golang.org/x/sys/windows"
13-
14-
"github.com/elastic/elastic-agent/internal/pkg/agent/errors"
15-
)
16-
17-
var UserOwnerMismatchError = errors.New("the command is executed as root but the program files are not owned by the root user.")
18-
19-
func getFileOwner(filePath string) (string, error) {
20-
// Get security information of the file
21-
sd, err := windows.GetNamedSecurityInfo(
22-
filePath,
23-
windows.SE_FILE_OBJECT,
24-
windows.OWNER_SECURITY_INFORMATION,
25-
)
26-
if err != nil {
27-
return "", fmt.Errorf("failed to get security info: %w", err)
28-
}
29-
owner, _, err := sd.Owner()
30-
if err != nil {
31-
return "", fmt.Errorf("failed to get security descriptor owner: %w", err)
32-
}
33-
34-
return owner.String(), nil
35-
}
36-
37-
// Helper to get the current user's SID
38-
func getCurrentUser() (string, error) {
39-
// Get the token for the current process
40-
var token windows.Token
41-
err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token)
42-
if err != nil {
43-
return "", fmt.Errorf("failed to open process token: %w", err)
44-
}
45-
defer token.Close()
46-
47-
// Get the token use
48-
tokenUser, err := token.GetTokenUser()
49-
if err != nil {
50-
return "", fmt.Errorf("failed to get token user: %w", err)
51-
}
52-
53-
return tokenUser.User.Sid.String(), nil
54-
}
55-
56-
func isFileOwner(curUser string, fileOwner string) (bool, error) {
57-
var cSid *windows.SID
58-
err := windows.ConvertStringSidToSid(windows.StringToUTF16Ptr(curUser), &cSid)
59-
if err != nil {
60-
return false, fmt.Errorf("failed to convert user SID string to SID: %w", err)
61-
}
62-
63-
var fSid *windows.SID
64-
err = windows.ConvertStringSidToSid(windows.StringToUTF16Ptr(fileOwner), &fSid)
65-
if err != nil {
66-
return false, fmt.Errorf("failed to convert file SID string to SID: %w", err)
67-
}
68-
69-
isEqual := fSid.Equals(cSid)
70-
71-
return isEqual, nil
72-
}
73-
74-
// Checks if the provided file is owned by the user that initiated the process
75-
func isOwnerExec(filePath string) (bool, error) {
76-
fileOwner, err := getFileOwner(filePath)
77-
if err != nil {
78-
return false, fmt.Errorf("getting file owner: %w", err)
79-
}
80-
81-
user, err := getCurrentUser()
82-
if err != nil {
83-
return false, fmt.Errorf("ran into an error while retrieving current user: %w", err)
84-
}
85-
86-
isOwner, err := isFileOwner(user, fileOwner)
87-
if err != nil {
88-
return false, fmt.Errorf("error while checking if current user is the file owner: %w", err)
89-
}
90-
91-
return isOwner, nil
9+
func isOwnerExec(path string) (bool, error) {
10+
// No-op for Windows: always allow
11+
return true, nil
9212
}

internal/pkg/agent/cmd/enroll_match_fileowner_windows_test.go

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@
77
package cmd
88

99
import (
10-
"fmt"
1110
"os"
1211
"path/filepath"
1312
"testing"
1413

1514
"github.com/stretchr/testify/require"
16-
"golang.org/x/sys/windows"
1715
)
1816

1917
func TestIsOwnerExecWindows(t *testing.T) {
@@ -23,30 +21,8 @@ func TestIsOwnerExecWindows(t *testing.T) {
2321
require.NoError(t, err)
2422
defer fi.Close()
2523

26-
var token windows.Token
27-
err = windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token)
28-
require.NoError(t, err)
29-
defer token.Close()
30-
31-
tokenUser, err := token.GetTokenUser()
32-
require.NoError(t, err)
33-
34-
err = windows.SetNamedSecurityInfo(
35-
fp,
36-
windows.SE_FILE_OBJECT,
37-
windows.OWNER_SECURITY_INFORMATION,
38-
tokenUser.User.Sid,
39-
nil,
40-
nil,
41-
nil,
42-
)
43-
require.NoError(t, err)
44-
45-
require.NoError(t, err)
46-
defer fi.Close()
47-
4824
isOwner, err := isOwnerExec(fp)
4925
require.NoError(t, err)
5026

51-
require.True(t, isOwner, fmt.Sprintf("expected isOwnerExec to return \"true\", received \"%v\"", isOwner))
27+
require.True(t, isOwner)
5228
}

testing/integration/enroll_unprivileged_test.go

Lines changed: 0 additions & 71 deletions
This file was deleted.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
//go:build integration
6+
7+
package integration
8+
9+
import (
10+
"context"
11+
"testing"
12+
"time"
13+
14+
"github.com/gofrs/uuid/v5"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
18+
"github.com/elastic/elastic-agent-libs/kibana"
19+
"github.com/elastic/elastic-agent/internal/pkg/agent/cmd"
20+
atesting "github.com/elastic/elastic-agent/pkg/testing"
21+
"github.com/elastic/elastic-agent/pkg/testing/define"
22+
"github.com/elastic/elastic-agent/pkg/testing/tools"
23+
"github.com/elastic/elastic-agent/pkg/testing/tools/fleettools"
24+
)
25+
26+
type AssertFunc func(*testing.T, *atesting.Fixture, string, error)
27+
28+
type testCase struct {
29+
description string
30+
privileged bool
31+
os []define.OS
32+
assertion AssertFunc
33+
}
34+
35+
// TestReEnrollUnprivileged verifies that re-enrollment as a privileged user fails
36+
// when the agent was installed unprivileged. This enforces the file ownership check
37+
// on Unix platforms. On Windows, this check is a no-op as of PR #8503, so this test
38+
// is not run for windows. See the discussion in PR #8503 (https://github.com/elastic/elastic-agent/pull/8503)
39+
// and comment (https://github.com/elastic/elastic-agent/pull/8503#discussion_r2152603141) for context.
40+
func TestReEnrollUnprivileged(t *testing.T) {
41+
info := define.Require(t, define.Requirements{
42+
Group: Default,
43+
Stack: &define.Stack{},
44+
Sudo: true,
45+
OS: []define.OS{
46+
{Type: define.Darwin},
47+
{Type: define.Linux},
48+
},
49+
})
50+
51+
ctx := t.Context()
52+
53+
fixture, enrollArgs := prepareAgentforReEnroll(t, ctx, info, false)
54+
55+
out, err := fixture.Exec(ctx, enrollArgs)
56+
require.Error(t, err)
57+
require.Contains(t, string(out), cmd.UserOwnerMismatchError.Error())
58+
59+
assert.Eventuallyf(t, func() bool {
60+
err := fixture.IsHealthy(t.Context())
61+
return err == nil
62+
},
63+
2*time.Minute, time.Second,
64+
"Elastic-Agent did not report healthy. Agent status error: \"%v\"",
65+
err,
66+
)
67+
}
68+
69+
func TestReEnrollPrivileged(t *testing.T) {
70+
info := define.Require(t, define.Requirements{
71+
Group: Default,
72+
Stack: &define.Stack{},
73+
Sudo: true,
74+
})
75+
76+
ctx := t.Context()
77+
78+
fixture, enrollArgs := prepareAgentforReEnroll(t, ctx, info, true)
79+
_, err := fixture.Exec(ctx, enrollArgs)
80+
require.NoError(t, err)
81+
82+
assert.Eventuallyf(t, func() bool {
83+
err := fixture.IsHealthy(t.Context())
84+
return err == nil
85+
},
86+
2*time.Minute, time.Second,
87+
"Elastic-Agent did not report healthy. Agent status error: \"%v\"",
88+
err,
89+
)
90+
}
91+
92+
func prepareAgentforReEnroll(t *testing.T, ctx context.Context, info *define.Info, privileged bool) (*atesting.Fixture, []string) {
93+
fixture, err := define.NewFixtureFromLocalBuild(t, define.Version())
94+
require.NoError(t, err)
95+
installOpts := atesting.InstallOpts{
96+
NonInteractive: true,
97+
Force: true,
98+
Privileged: privileged,
99+
}
100+
101+
randId := uuid.Must(uuid.NewV4()).String()
102+
policyReq := kibana.AgentPolicy{
103+
Name: "test-policy-" + randId,
104+
Namespace: "default",
105+
Description: "Test policy " + randId,
106+
MonitoringEnabled: []kibana.MonitoringEnabledOption{
107+
kibana.MonitoringEnabledLogs,
108+
kibana.MonitoringEnabledMetrics,
109+
},
110+
}
111+
policy, err := info.KibanaClient.CreatePolicy(ctx, policyReq)
112+
require.NoError(t, err)
113+
114+
enrollmentApiKey, err := tools.CreateEnrollmentToken(t, ctx, info.KibanaClient, policy.ID)
115+
require.NoError(t, err)
116+
117+
_, err = tools.InstallAgentForPolicyWithToken(ctx, t, installOpts, fixture, info.KibanaClient, enrollmentApiKey)
118+
require.NoError(t, err)
119+
120+
enrollUrl, err := fleettools.DefaultURL(ctx, info.KibanaClient)
121+
require.NoError(t, err)
122+
123+
return fixture, []string{"enroll", "--url", enrollUrl, "--enrollment-token", enrollmentApiKey.APIKey, "--force"}
124+
}

0 commit comments

Comments
 (0)