Skip to content

Commit 113e6ad

Browse files
author
Kaan Yalti
authored
Fix/8544 windows unprivileged reenroll (#9623)
* fix(8544): implemented windows getOwnerFromPath * fix(8544): refactored windows getFileOwnerFromPath to be more testable, added tests fix(8544): fix build tag * fix(8544): removed windows check from enroll * fix(8544): removed os config from reenroll test, and removed unused struct * fix(8544): update re-enroll tests * fis(8544): added chengelog fragment * fix(8544): ran mage check * fix(8544): fix windows tests
1 parent 47111a9 commit 113e6ad

File tree

6 files changed

+174
-59
lines changed

6 files changed

+174
-59
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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: enable admin user to re-enroll unprivileged agent for 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+
21+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
22+
component: "elastic-agent"
23+
24+
# PR URL; optional; the PR number that added the changeset.
25+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
26+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
27+
# Please provide it if you are adding a fragment for a different PR.
28+
pr: https://github.com/elastic/elastic-agent/pull/9623
29+
30+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
31+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
32+
issue: https://github.com/elastic/elastic-agent/issues/8544

internal/pkg/agent/cmd/enroll.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ func computeFixPermissions(fromInstall bool, hasRoot bool, os string, getFileOwn
389389
return &perms, nil
390390
}
391391

392-
if hasRoot && os != "windows" { // windows is a no-op, will be addressed in a separate PR
392+
if hasRoot {
393393
perms, err := getOwnerFromPath(paths.Top())
394394
if err != nil {
395395
return nil, fmt.Errorf("failed to get owner from path %s: %w", paths.Top(), err)

internal/pkg/agent/cmd/enroll_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,14 @@ func TestComputeFixPermissions(t *testing.T) {
7070
expectOwnerFromCmdCalled: false,
7171
expectOwnerFromPathCalled: true,
7272
},
73-
"should skip fixing permissions when not from installer with root on windows": {
73+
"should return owner from path when not from install and has root on windows": {
7474
fromInstall: false,
7575
hasRoot: true,
7676
goos: "windows",
77-
wantOwner: nil,
77+
ownerFromPathOwner: owner,
78+
wantOwner: &owner,
7879
expectOwnerFromCmdCalled: false,
79-
expectOwnerFromPathCalled: false,
80+
expectOwnerFromPathCalled: true,
8081
},
8182
"should skip fixing permissions when not from installer without root": {
8283
fromInstall: false,

internal/pkg/agent/cmd/enroll_windows.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/spf13/cobra"
1313
"golang.org/x/sys/windows"
1414

15+
"github.com/elastic/elastic-agent/internal/pkg/acl"
1516
"github.com/elastic/elastic-agent/pkg/utils"
1617
)
1718

@@ -54,7 +55,42 @@ func getSIDFromCmd(cmd *cobra.Command, param string) (*windows.SID, error) {
5455
return nil, nil
5556
}
5657

58+
// getOwnerFromPath calls getOwnerFromPathWindows for testability. This way we
59+
// can inject the windows specific functions.
5760
func getOwnerFromPath(path string) (utils.FileOwner, error) {
58-
// TODO: Implement this
59-
return utils.FileOwner{}, nil
61+
return getOwnerFromPathWindows(path, acl.GetNamedSecurityInfo, windows.LocalFree)
62+
}
63+
64+
type getNamedSecurityInfo func(objectName string, objectType int32, secInfo uint32, owner, group **windows.SID, dacl, sacl, secDesc *windows.Handle) error
65+
type localFree func(handle windows.Handle) (windows.Handle, error)
66+
67+
func getOwnerFromPathWindows(path string, getNamedSecurityInfo getNamedSecurityInfo, localFree localFree) (utils.FileOwner, error) {
68+
var ownerSID *windows.SID
69+
var groupSID *windows.SID
70+
var secDesc windows.Handle
71+
72+
if err := getNamedSecurityInfo(
73+
path,
74+
acl.SE_FILE_OBJECT,
75+
acl.OWNER_SECURITY_INFORMATION|acl.GROUP_SECURITY_INFORMATION,
76+
&ownerSID,
77+
&groupSID,
78+
nil,
79+
nil,
80+
&secDesc,
81+
); err != nil {
82+
return utils.FileOwner{}, fmt.Errorf("failed to get security info for %s: %w", path, err)
83+
}
84+
85+
defer localFree(secDesc) //nolint:errcheck // not much we can do
86+
87+
var ownership utils.FileOwner
88+
if ownerSID == nil || groupSID == nil {
89+
return utils.FileOwner{}, fmt.Errorf("failed to get owner or group SID for %s", path)
90+
}
91+
92+
ownership.UID = ownerSID.String()
93+
ownership.GID = groupSID.String()
94+
95+
return ownership, nil
6096
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 windows
6+
7+
package cmd
8+
9+
import (
10+
"errors"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
"golang.org/x/sys/windows"
15+
16+
"github.com/elastic/elastic-agent/pkg/utils"
17+
)
18+
19+
func TestGetOwnerFromPathWindows(t *testing.T) {
20+
21+
ownerSID, err := windows.StringToSid(utils.AdministratorSID)
22+
require.NoError(t, err)
23+
groupSID, err := windows.StringToSid(utils.AdministratorSID)
24+
require.NoError(t, err)
25+
26+
testError := errors.New("test error")
27+
28+
mockGetNamedSecurityInfoFactory := func(err error) getNamedSecurityInfo {
29+
return func(objectName string, objectType int32, secInfo uint32, owner, group **windows.SID, dacl, sacl, secDesc *windows.Handle) error {
30+
*owner = ownerSID
31+
*group = groupSID
32+
return err
33+
}
34+
}
35+
mockLocalFree := func(handle windows.Handle) (windows.Handle, error) {
36+
return windows.Handle(0), nil
37+
}
38+
39+
type testCase struct {
40+
mockGetNamedSecurityInfo getNamedSecurityInfo
41+
mockLocalFree localFree
42+
wantOwner utils.FileOwner
43+
wantErr bool
44+
}
45+
46+
testCases := map[string]testCase{
47+
"returns owner when getNamedSecurityInfo succeeds": {
48+
mockGetNamedSecurityInfo: mockGetNamedSecurityInfoFactory(nil),
49+
mockLocalFree: mockLocalFree,
50+
wantOwner: utils.FileOwner{UID: utils.AdministratorSID, GID: utils.AdministratorSID},
51+
wantErr: false,
52+
},
53+
"returns error when getNamedSecurityInfo fails": {
54+
mockGetNamedSecurityInfo: mockGetNamedSecurityInfoFactory(testError),
55+
mockLocalFree: mockLocalFree,
56+
wantOwner: utils.FileOwner{},
57+
wantErr: true,
58+
},
59+
}
60+
61+
for name, tc := range testCases {
62+
t.Run(name, func(t *testing.T) {
63+
owner, err := getOwnerFromPathWindows("test", tc.mockGetNamedSecurityInfo, tc.mockLocalFree)
64+
require.Equal(t, tc.wantOwner, owner)
65+
66+
if tc.wantErr {
67+
require.Error(t, err)
68+
return
69+
}
70+
71+
require.NoError(t, err)
72+
})
73+
}
74+
}

testing/integration/ess/re-enroll_test.go

Lines changed: 25 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -23,70 +23,42 @@ import (
2323
"github.com/elastic/elastic-agent/testing/integration"
2424
)
2525

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-
// Verifies that re-enrollment as a privileged user succeeds when the agent was
36-
// installed unprivileged. Windows implementation is a no-op and will be addressed
37-
// in a separate PR. Relevant issue: https://github.com/elastic/elastic-agent/issues/8544
26+
// Verifies that re-enrolling agent as a privileged user succeeds when the agent
27+
// is both unprivileged and privileged.
3828
func TestReEnrollUnprivileged(t *testing.T) {
3929
info := define.Require(t, define.Requirements{
4030
Group: integration.Default,
4131
Stack: &define.Stack{},
4232
Sudo: true,
43-
OS: []define.OS{
44-
{Type: define.Darwin},
45-
{Type: define.Linux},
46-
},
4733
})
4834

4935
ctx := t.Context()
5036

51-
fixture, enrollArgs := prepareAgentforReEnroll(t, ctx, info, false)
52-
53-
out, err := fixture.Exec(ctx, enrollArgs)
54-
if out != nil {
55-
t.Log(string(out))
37+
testCases := map[string]bool{
38+
"unprivileged agent with privileged user": false,
39+
"privileged agent with privileged user": true,
5640
}
57-
require.NoError(t, err)
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-
}
6841

69-
func TestReEnrollPrivileged(t *testing.T) {
70-
info := define.Require(t, define.Requirements{
71-
Group: integration.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-
)
42+
for name, privileged := range testCases {
43+
t.Run(name, func(t *testing.T) {
44+
fixture, enrollArgs := prepareAgentforReEnroll(t, ctx, info, privileged)
45+
46+
out, err := fixture.Exec(ctx, enrollArgs)
47+
if out != nil {
48+
t.Log(string(out))
49+
}
50+
require.NoError(t, err)
51+
52+
assert.Eventuallyf(t, func() bool {
53+
err := fixture.IsHealthy(t.Context())
54+
return err == nil
55+
},
56+
2*time.Minute, time.Second,
57+
"Elastic-Agent did not report healthy. Agent status error: \"%v\"",
58+
err,
59+
)
60+
})
61+
}
9062
}
9163

9264
func prepareAgentforReEnroll(t *testing.T, ctx context.Context, info *define.Info, privileged bool) (*atesting.Fixture, []string) {

0 commit comments

Comments
 (0)