Skip to content

Commit b8f864b

Browse files
Add /api/fleet/agents/:id/audit/unenroll endpoint (#3818)
Add /api/fleet/agents/:id/audit/unenroll API that an elastic-agent or Endpoint process may use to annotate the agent document so the agent may appear with a different status.
1 parent 4cd41c8 commit b8f864b

30 files changed

+953
-43
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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: feature
12+
13+
# Change summary; a 80ish characters long description of the change.
14+
summary: Add audit/unenroll API
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+
Add the /api/fleet/agents/:id/audit/unenroll API that elastic-agent
21+
and Endpoint instances may use to annotate the agent document when
22+
the agent is uninstalled or Endpoint detects it is in an orphaned
23+
state.
24+
25+
# Affected component; a word indicating the component this changeset affects.
26+
component:
27+
28+
# PR URL; optional; the PR number that added the changeset.
29+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
30+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
31+
# Please provide it if you are adding a fragment for a different PR.
32+
pr: https://github.com/elastic/fleet-server/pull/3818
33+
34+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
35+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
36+
issue: https://github.com/elastic/elastic-agent/issues/484

fleet-server.reference.yml

+5
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ fleet:
222222
# burst: 100
223223
# max: 50
224224
# max_body_byte_size: 0
225+
# audit_unenroll_limit:
226+
# interval: 10ms
227+
# burst: 100
228+
# max: 50
229+
# max_body_byte_size: 1024
225230
#
226231
# # go runtime limits
227232
# runtime:

internal/pkg/api/api.go

+10
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type apiServer struct {
2828
ut *UploadT
2929
ft *FileDeliveryT
3030
pt *PGPRetrieverT
31+
audit *AuditT
3132
bulker bulk.Bulk
3233
}
3334

@@ -148,6 +149,15 @@ func (a *apiServer) GetPGPKey(w http.ResponseWriter, r *http.Request, major, min
148149
}
149150
}
150151

152+
func (a *apiServer) AuditUnenroll(w http.ResponseWriter, r *http.Request, id string, params AuditUnenrollParams) {
153+
zlog := hlog.FromRequest(r).With().Str(LogAgentID, id).Logger()
154+
if err := a.audit.handleUnenroll(zlog, w, r, id); err != nil {
155+
w.Header().Set("Content-Type", "application/json")
156+
cntAuditUnenroll.IncError(err)
157+
ErrorResp(w, r, err)
158+
}
159+
}
160+
151161
func (a *apiServer) Status(w http.ResponseWriter, r *http.Request, params StatusParams) {
152162
zlog := hlog.FromRequest(r).With().
153163
Str("mod", kStatusMod).

internal/pkg/api/error.go

+10
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,16 @@ func NewHTTPErrResp(err error) HTTPErrResp {
481481
zerolog.InfoLevel,
482482
},
483483
},
484+
// audit unenroll
485+
{
486+
ErrAuditUnenrollReason,
487+
HTTPErrResp{
488+
http.StatusConflict,
489+
"ErrAuditReasonConflict",
490+
"agent document contains audit_unenroll_reason",
491+
zerolog.InfoLevel,
492+
},
493+
},
484494
}
485495

486496
for _, e := range errTable {

internal/pkg/api/handleAudit.go

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
package api
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"fmt"
11+
"net/http"
12+
"time"
13+
14+
"github.com/elastic/fleet-server/v7/internal/pkg/bulk"
15+
"github.com/elastic/fleet-server/v7/internal/pkg/cache"
16+
"github.com/elastic/fleet-server/v7/internal/pkg/config"
17+
"github.com/elastic/fleet-server/v7/internal/pkg/dl"
18+
"github.com/elastic/fleet-server/v7/internal/pkg/model"
19+
20+
"github.com/miolini/datacounter"
21+
"github.com/rs/zerolog"
22+
"go.elastic.co/apm/v2"
23+
)
24+
25+
var ErrAuditUnenrollReason = fmt.Errorf("agent document contains audit_unenroll_reason attribute")
26+
27+
type AuditT struct {
28+
cfg *config.Server
29+
bulk bulk.Bulk
30+
cache cache.Cache
31+
}
32+
33+
func NewAuditT(cfg *config.Server, bulker bulk.Bulk, cache cache.Cache) *AuditT {
34+
return &AuditT{
35+
cfg: cfg,
36+
bulk: bulker,
37+
cache: cache,
38+
}
39+
}
40+
41+
func (audit *AuditT) handleUnenroll(zlog zerolog.Logger, w http.ResponseWriter, r *http.Request, id string) error {
42+
agent, err := authAgent(r, &id, audit.bulk, audit.cache)
43+
if err != nil {
44+
return err
45+
}
46+
zlog = zlog.With().Str(LogAccessAPIKeyID, agent.AccessAPIKeyID).Logger()
47+
ctx := zlog.WithContext(r.Context())
48+
r = r.WithContext(ctx)
49+
50+
return audit.unenroll(zlog, w, r, agent)
51+
}
52+
53+
func (audit *AuditT) unenroll(zlog zerolog.Logger, w http.ResponseWriter, r *http.Request, agent *model.Agent) error {
54+
if agent.AuditUnenrolledReason != "" {
55+
return ErrAuditUnenrollReason
56+
}
57+
58+
req, err := audit.validateUnenrollRequest(zlog, w, r)
59+
if err != nil {
60+
return err
61+
}
62+
63+
if err := audit.markUnenroll(r.Context(), zlog, req, agent); err != nil {
64+
return err
65+
}
66+
67+
span, _ := apm.StartSpan(r.Context(), "response", "write")
68+
defer span.End()
69+
w.WriteHeader(http.StatusOK)
70+
return nil
71+
}
72+
73+
func (audit *AuditT) validateUnenrollRequest(zlog zerolog.Logger, w http.ResponseWriter, r *http.Request) (*AuditUnenrollRequest, error) {
74+
span, _ := apm.StartSpan(r.Context(), "validateRequest", "validate")
75+
defer span.End()
76+
77+
body := r.Body
78+
if audit.cfg.Limits.AuditUnenrollLimit.MaxBody > 0 {
79+
body = http.MaxBytesReader(w, body, audit.cfg.Limits.AuditUnenrollLimit.MaxBody)
80+
}
81+
readCounter := datacounter.NewReaderCounter(body)
82+
83+
var req AuditUnenrollRequest
84+
dec := json.NewDecoder(readCounter)
85+
if err := dec.Decode(&req); err != nil {
86+
return nil, &BadRequestErr{msg: "unable to decode audit/unenroll request", nextErr: err}
87+
}
88+
89+
switch req.Reason {
90+
case Uninstall, Orphaned, KeyRevoked:
91+
default:
92+
return nil, &BadRequestErr{msg: "audit/unenroll request invalid reason"}
93+
}
94+
95+
cntAuditUnenroll.bodyIn.Add(readCounter.Count())
96+
zlog.Trace().Msg("Audit unenroll request")
97+
return &req, nil
98+
}
99+
100+
func (audit *AuditT) markUnenroll(ctx context.Context, zlog zerolog.Logger, req *AuditUnenrollRequest, agent *model.Agent) error {
101+
span, ctx := apm.StartSpan(ctx, "auditUnenroll", "process")
102+
defer span.End()
103+
104+
now := time.Now().UTC().Format(time.RFC3339)
105+
doc := bulk.UpdateFields{
106+
dl.FieldUnenrolledAt: now,
107+
dl.FieldUpdatedAt: now,
108+
dl.FieldAuditUnenrolledTime: req.Timestamp,
109+
dl.FieldAuditUnenrolledReason: req.Reason,
110+
}
111+
body, err := doc.Marshal()
112+
if err != nil {
113+
return fmt.Errorf("auditUnenroll marshal: %w", err)
114+
}
115+
116+
if err := audit.bulk.Update(ctx, dl.FleetAgents, agent.Id, body, bulk.WithRefresh(), bulk.WithRetryOnConflict(3)); err != nil {
117+
return fmt.Errorf("auditUnenroll update: %w", err)
118+
}
119+
120+
zlog.Info().Msg("audit unenroll successful")
121+
return nil
122+
}

internal/pkg/api/handleAudit_test.go

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
package api
6+
7+
import (
8+
"context"
9+
"io"
10+
"net/http"
11+
"net/http/httptest"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/elastic/fleet-server/v7/internal/pkg/config"
17+
"github.com/elastic/fleet-server/v7/internal/pkg/dl"
18+
"github.com/elastic/fleet-server/v7/internal/pkg/model"
19+
ftesting "github.com/elastic/fleet-server/v7/internal/pkg/testing"
20+
testlog "github.com/elastic/fleet-server/v7/internal/pkg/testing/log"
21+
"github.com/stretchr/testify/mock"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func Test_Audit_validateUnenrollRequst(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
req *http.Request
29+
cfg *config.Server
30+
valid *AuditUnenrollRequest
31+
err error
32+
}{{
33+
name: "ok",
34+
req: &http.Request{
35+
Body: io.NopCloser(strings.NewReader(`{"reason":"uninstall", "timestamp": "2024-01-01T12:00:00.000Z"}`)),
36+
},
37+
cfg: &config.Server{},
38+
valid: &AuditUnenrollRequest{
39+
Reason: Uninstall,
40+
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
41+
},
42+
err: nil,
43+
}, {
44+
name: "not json object",
45+
req: &http.Request{
46+
Body: io.NopCloser(strings.NewReader(`{"invalidJson":}`)),
47+
},
48+
cfg: &config.Server{},
49+
valid: nil,
50+
err: &BadRequestErr{msg: "unable to decode audit/unenroll request"},
51+
}, {
52+
name: "bad reason",
53+
req: &http.Request{
54+
Body: io.NopCloser(strings.NewReader(`{"reason":"bad reason", "timestamp": "2024-01-01T12:00:00.000Z"}`)),
55+
},
56+
cfg: &config.Server{},
57+
valid: nil,
58+
err: &BadRequestErr{msg: "audit/unenroll request invalid reason"},
59+
}, {
60+
name: "too large",
61+
req: &http.Request{
62+
Body: io.NopCloser(strings.NewReader(`{"reason":"uninstalled", "timestamp": "2024-01-01T12:00:00.000Z"}`)),
63+
},
64+
cfg: &config.Server{
65+
Limits: config.ServerLimits{
66+
AuditUnenrollLimit: config.Limit{
67+
MaxBody: 10,
68+
},
69+
},
70+
},
71+
valid: nil,
72+
err: &BadRequestErr{msg: "unable to decode audit/unenroll request"},
73+
}}
74+
75+
for _, tc := range tests {
76+
t.Run(tc.name, func(t *testing.T) {
77+
audit := AuditT{cfg: tc.cfg}
78+
w := httptest.NewRecorder()
79+
80+
r, err := audit.validateUnenrollRequest(testlog.SetLogger(t), w, tc.req)
81+
if tc.err != nil {
82+
require.EqualError(t, err, tc.err.Error())
83+
} else {
84+
require.NoError(t, err)
85+
}
86+
require.Equal(t, tc.valid, r)
87+
})
88+
}
89+
}
90+
91+
func Test_Audit_markUnenroll(t *testing.T) {
92+
agent := &model.Agent{
93+
ESDocument: model.ESDocument{
94+
Id: "test-id",
95+
},
96+
}
97+
bulker := ftesting.NewMockBulk()
98+
bulker.On("Update", mock.Anything, dl.FleetAgents, agent.Id, mock.Anything, mock.Anything, mock.Anything).Return(nil)
99+
audit := AuditT{bulk: bulker}
100+
logger := testlog.SetLogger(t)
101+
err := audit.markUnenroll(context.Background(), logger, &AuditUnenrollRequest{Reason: Uninstall, Timestamp: time.Now().UTC()}, agent)
102+
require.NoError(t, err)
103+
bulker.AssertExpectations(t)
104+
}
105+
106+
func Test_Audit_unenroll(t *testing.T) {
107+
t.Run("agent has audit_unenroll_reason", func(t *testing.T) {
108+
agent := &model.Agent{
109+
AuditUnenrolledReason: string(Uninstall),
110+
}
111+
audit := &AuditT{}
112+
err := audit.unenroll(testlog.SetLogger(t), nil, nil, agent)
113+
require.EqualError(t, err, ErrAuditUnenrollReason.Error())
114+
})
115+
116+
t.Run("ok", func(t *testing.T) {
117+
agent := &model.Agent{
118+
ESDocument: model.ESDocument{
119+
Id: "test-id",
120+
},
121+
}
122+
bulker := ftesting.NewMockBulk()
123+
bulker.On("Update", mock.Anything, dl.FleetAgents, agent.Id, mock.Anything, mock.Anything, mock.Anything).Return(nil)
124+
125+
audit := &AuditT{
126+
bulk: bulker,
127+
cfg: &config.Server{},
128+
}
129+
req := &http.Request{
130+
Body: io.NopCloser(strings.NewReader(`{"reason": "uninstall", "timestamp": "2024-01-01T12:00:00.000Z"}`)),
131+
}
132+
err := audit.unenroll(testlog.SetLogger(t), httptest.NewRecorder(), req, agent)
133+
require.NoError(t, err)
134+
bulker.AssertExpectations(t)
135+
})
136+
}

internal/pkg/api/metrics.go

+12-11
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,17 @@ var (
3535
cntHTTPClose *statsCounter
3636
cntHTTPActive *statsGauge
3737

38-
cntCheckin routeStats
39-
cntEnroll routeStats
40-
cntAcks routeStats
41-
cntStatus routeStats
42-
cntUploadStart routeStats
43-
cntUploadChunk routeStats
44-
cntUploadEnd routeStats
45-
cntFileDeliv routeStats
46-
cntGetPGP routeStats
47-
cntArtifacts artifactStats
38+
cntCheckin routeStats
39+
cntEnroll routeStats
40+
cntAcks routeStats
41+
cntStatus routeStats
42+
cntUploadStart routeStats
43+
cntUploadChunk routeStats
44+
cntUploadEnd routeStats
45+
cntFileDeliv routeStats
46+
cntGetPGP routeStats
47+
cntAuditUnenroll routeStats
48+
cntArtifacts artifactStats
4849

4950
infoReg sync.Once
5051
)
@@ -75,7 +76,7 @@ func init() {
7576
cntUploadEnd.Register(routesRegistry.newRegistry("uploadEnd"))
7677
cntFileDeliv.Register(routesRegistry.newRegistry("deliverFile"))
7778
cntGetPGP.Register(routesRegistry.newRegistry("getPGPKey"))
78-
79+
cntAuditUnenroll.Register(routesRegistry.newRegistry("auditUnenroll"))
7980
}
8081

8182
// metricsRegistry wraps libbeat and prometheus registries

0 commit comments

Comments
 (0)