From 1fd3968626e33d43999095e028302edb130f1179 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Mon, 11 May 2026 15:44:07 -0700 Subject: [PATCH 01/19] fix: enforce policy-based access control on artifact downloads The artifact download endpoint (/api/fleet/artifacts/{id}/{sha256}) previously only validated the agent's API key but never checked whether the requested artifact belonged to the agent's assigned policy. This allowed an agent enrolled under one policy to download artifacts from a different policy if it knew the artifact ID and SHA256 hash. Add authorizeArtifact implementation that fetches the agent's policy from the in-memory policy monitor cache and verifies the requested artifact appears in the policy's artifact_manifest before serving it. Returns 403 Forbidden if the artifact is not in the agent's policy. Resolves: https://github.com/elastic/security/issues/8396 Co-Authored-By: Claude Opus 4.6 --- internal/pkg/api/error.go | 18 ++++ internal/pkg/api/handleArtifacts.go | 77 ++++++++++--- internal/pkg/api/handleArtifacts_test.go | 131 +++++++++++++++++++++++ internal/pkg/api/handleCheckin_test.go | 6 ++ internal/pkg/policy/monitor.go | 34 ++++++ internal/pkg/server/fleet.go | 2 +- 6 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 internal/pkg/api/handleArtifacts_test.go diff --git a/internal/pkg/api/error.go b/internal/pkg/api/error.go index 2e27df6655..29aff2e62e 100644 --- a/internal/pkg/api/error.go +++ b/internal/pkg/api/error.go @@ -142,6 +142,24 @@ func NewHTTPErrResp(err error) HTTPErrResp { zerolog.WarnLevel, }, }, + { + ErrUnauthorizedArtifact, + HTTPErrResp{ + http.StatusForbidden, + "Forbidden", + "agent not authorized for artifact", + zerolog.WarnLevel, + }, + }, + { + ErrAgentPolicyIDMissing, + HTTPErrResp{ + http.StatusForbidden, + "Forbidden", + "agent has no policy ID", + zerolog.WarnLevel, + }, + }, { ErrorThrottle, HTTPErrResp{ diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index 1074af5bed..0d2bca6ac4 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -25,6 +25,7 @@ import ( "github.com/elastic/fleet-server/v7/internal/pkg/dl" "github.com/elastic/fleet-server/v7/internal/pkg/logger" "github.com/elastic/fleet-server/v7/internal/pkg/model" + "github.com/elastic/fleet-server/v7/internal/pkg/policy" "github.com/elastic/fleet-server/v7/internal/pkg/throttle" "github.com/rs/zerolog" @@ -36,23 +37,27 @@ const ( ) var ( - ErrorThrottle = errors.New("cannot acquire throttle token") - ErrorBadSha2 = errors.New("malformed sha256") - ErrorRecord = errors.New("artifact record mismatch") - ErrorMismatchSha2 = errors.New("mismatched sha256") + ErrorThrottle = errors.New("cannot acquire throttle token") + ErrorBadSha2 = errors.New("malformed sha256") + ErrorRecord = errors.New("artifact record mismatch") + ErrorMismatchSha2 = errors.New("mismatched sha256") + ErrUnauthorizedArtifact = errors.New("agent not authorized for artifact") + ErrAgentPolicyIDMissing = errors.New("agent has no policy ID") ) type ArtifactT struct { bulker bulk.Bulk cache cache.Cache esThrottle *throttle.Throttle + pm policy.Monitor } -func NewArtifactT(cfg *config.Server, bulker bulk.Bulk, cache cache.Cache) *ArtifactT { +func NewArtifactT(cfg *config.Server, bulker bulk.Bulk, cache cache.Cache, pm policy.Monitor) *ArtifactT { return &ArtifactT{ bulker: bulker, cache: cache, esThrottle: throttle.NewThrottle(defaultMaxParallel), + pm: pm, } } @@ -138,19 +143,57 @@ func (at ArtifactT) processRequest(ctx context.Context, zlog zerolog.Logger, age return rdr, nil } -// TODO: Pull the policy record for this agent and validate that the -// requested artifact is assigned to this policy. This will prevent -// agents from retrieving artifacts that they do not have access to. -// Note that this is racy, the policy could have changed to allow an -// artifact before this instantiation of FleetServer has its local -// copy updated. Take the race conditions into consideration. -// -// Initial implementation is dependent on security by obscurity; ie. -// it should be difficult for an attacker to guess a guid. -func (at ArtifactT) authorizeArtifact(ctx context.Context, _ *model.Agent, _, _ string) error { - span, _ := apm.StartSpan(ctx, "authorizeArtifacts", "auth") // TODO return and use span ctx if this is ever not a nop +func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, id, sha2 string) error { + span, ctx := apm.StartSpan(ctx, "authorizeArtifacts", "auth") defer span.End() - return nil // TODO + + if agent.AgentPolicyID == "" { + return ErrAgentPolicyIDMissing + } + + p, err := at.pm.GetPolicy(ctx, agent.AgentPolicyID) + if err != nil { + return fmt.Errorf("authorizeArtifact: %w", err) + } + + if p.Data != nil && policyHasArtifact(p.Data, id, sha2) { + return nil + } + + return ErrUnauthorizedArtifact +} + +func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { + for _, input := range pd.Inputs { + am, ok := input["artifact_manifest"] + if !ok { + continue + } + amMap, ok := am.(map[string]interface{}) + if !ok { + continue + } + artifacts, ok := amMap["artifacts"] + if !ok { + continue + } + artifactsMap, ok := artifacts.(map[string]interface{}) + if !ok { + continue + } + artEntry, ok := artifactsMap[id] + if !ok { + continue + } + artMap, ok := artEntry.(map[string]interface{}) + if !ok { + continue + } + if ds, ok := artMap["decoded_sha256"].(string); ok && ds == sha2 { + return true + } + } + return false } // Return artifact from cache by sha2 or fetch directly from Elastic. diff --git a/internal/pkg/api/handleArtifacts_test.go b/internal/pkg/api/handleArtifacts_test.go new file mode 100644 index 0000000000..9a455f294d --- /dev/null +++ b/internal/pkg/api/handleArtifacts_test.go @@ -0,0 +1,131 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package api + +import ( + "testing" + + "github.com/elastic/fleet-server/v7/internal/pkg/model" + "github.com/stretchr/testify/assert" +) + +func TestPolicyHasArtifact(t *testing.T) { + policyData := &model.PolicyData{ + Inputs: []map[string]interface{}{ + { + "type": "logfile", + "id": "logfile-1", + }, + { + "type": "endpoint", + "id": "endpoint-1", + "artifact_manifest": map[string]interface{}{ + "manifest_version": "1.0.28", + "schema_version": "v1", + "artifacts": map[string]interface{}{ + "endpoint-trustlist-windows-v1": map[string]interface{}{ + "decoded_sha256": "74c2255ce31e0b48ada298ed6dacf6d1be7b0fb40c1bcb251d2da66f4b060acf", + "decoded_size": float64(338), + }, + "endpoint-trustlist-linux-v1": map[string]interface{}{ + "decoded_sha256": "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", + "decoded_size": float64(14), + }, + }, + }, + }, + }, + } + + tests := []struct { + name string + id string + sha2 string + want bool + }{ + { + name: "matching artifact", + id: "endpoint-trustlist-windows-v1", + sha2: "74c2255ce31e0b48ada298ed6dacf6d1be7b0fb40c1bcb251d2da66f4b060acf", + want: true, + }, + { + name: "matching linux artifact", + id: "endpoint-trustlist-linux-v1", + sha2: "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", + want: true, + }, + { + name: "unknown artifact id", + id: "endpoint-blocklist-windows-v1", + sha2: "74c2255ce31e0b48ada298ed6dacf6d1be7b0fb40c1bcb251d2da66f4b060acf", + want: false, + }, + { + name: "wrong sha256 for known id", + id: "endpoint-trustlist-windows-v1", + sha2: "0000000000000000000000000000000000000000000000000000000000000000", + want: false, + }, + { + name: "empty id", + id: "", + sha2: "74c2255ce31e0b48ada298ed6dacf6d1be7b0fb40c1bcb251d2da66f4b060acf", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := policyHasArtifact(policyData, tt.id, tt.sha2) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPolicyHasArtifact_NoInputs(t *testing.T) { + pd := &model.PolicyData{} + assert.False(t, policyHasArtifact(pd, "endpoint-trustlist-linux-v1", "abc123")) +} + +func TestPolicyHasArtifact_NoArtifactManifest(t *testing.T) { + pd := &model.PolicyData{ + Inputs: []map[string]interface{}{ + {"type": "logfile"}, + }, + } + assert.False(t, policyHasArtifact(pd, "endpoint-trustlist-linux-v1", "abc123")) +} + +func TestPolicyHasArtifact_MultipleInputsWithArtifacts(t *testing.T) { + pd := &model.PolicyData{ + Inputs: []map[string]interface{}{ + {"type": "logfile"}, + { + "type": "endpoint", + "artifact_manifest": map[string]interface{}{ + "artifacts": map[string]interface{}{ + "endpoint-trustlist-linux-v1": map[string]interface{}{ + "decoded_sha256": "aaaa", + }, + }, + }, + }, + { + "type": "another-endpoint", + "artifact_manifest": map[string]interface{}{ + "artifacts": map[string]interface{}{ + "endpoint-blocklist-linux-v1": map[string]interface{}{ + "decoded_sha256": "bbbb", + }, + }, + }, + }, + }, + } + assert.True(t, policyHasArtifact(pd, "endpoint-trustlist-linux-v1", "aaaa")) + assert.True(t, policyHasArtifact(pd, "endpoint-blocklist-linux-v1", "bbbb")) + assert.False(t, policyHasArtifact(pd, "endpoint-trustlist-linux-v1", "bbbb")) +} diff --git a/internal/pkg/api/handleCheckin_test.go b/internal/pkg/api/handleCheckin_test.go index f288bb069e..52d87f2e10 100644 --- a/internal/pkg/api/handleCheckin_test.go +++ b/internal/pkg/api/handleCheckin_test.go @@ -64,6 +64,12 @@ func (m *mockPolicyMonitor) LatestRev(ctx context.Context, id string) int64 { return args.Get(0).(int64) } +func (m *mockPolicyMonitor) GetPolicy(ctx context.Context, policyID string) (*model.Policy, error) { + args := m.Called(ctx, policyID) + p, _ := args.Get(0).(*model.Policy) + return p, args.Error(1) +} + func TestConvertActionData(t *testing.T) { tests := []struct { name string diff --git a/internal/pkg/policy/monitor.go b/internal/pkg/policy/monitor.go index 36ed820aae..d4681190fe 100644 --- a/internal/pkg/policy/monitor.go +++ b/internal/pkg/policy/monitor.go @@ -7,6 +7,7 @@ package policy import ( "context" "errors" + "fmt" "sync" "time" @@ -65,6 +66,10 @@ type Monitor interface { // LatestRev returns the latest revision idx for the specified policy. LatestRev(ctx context.Context, policyID string) int64 + + // GetPolicy returns the cached policy for the given policy ID. + // If the policy is not in the cache, it forces a reload from Elasticsearch. + GetPolicy(ctx context.Context, policyID string) (*model.Policy, error) } type policyFetcher func(ctx context.Context, bulker bulk.Bulk, opt ...dl.Option) ([]model.Policy, error) @@ -591,3 +596,32 @@ func (m *monitorT) LatestRev(ctx context.Context, id string) int64 { } return p.pp.Policy.RevisionIdx } + +// GetPolicy returns the policy for the given policy ID from the in-memory cache. +// If the policy is not found in cache, all policies are reloaded from Elasticsearch. +func (m *monitorT) GetPolicy(ctx context.Context, policyID string) (*model.Policy, error) { + if policyID == "" { + return nil, errors.New("policy ID is empty") + } + + m.mut.Lock() + p, ok := m.policies[policyID] + m.mut.Unlock() + + if ok { + return &p.pp.Policy, nil + } + + if err := m.loadPolicies(ctx); err != nil { + return nil, fmt.Errorf("loading policies: %w", err) + } + + m.mut.Lock() + p, ok = m.policies[policyID] + m.mut.Unlock() + + if !ok { + return nil, fmt.Errorf("policy %s not found", policyID) + } + return &p.pp.Policy, nil +} diff --git a/internal/pkg/server/fleet.go b/internal/pkg/server/fleet.go index f1c1ef5b48..c3b98b1295 100644 --- a/internal/pkg/server/fleet.go +++ b/internal/pkg/server/fleet.go @@ -533,7 +533,7 @@ func (f *Fleet) runSubsystems(ctx context.Context, cfg *config.Config, g *errgro return err } - at := api.NewArtifactT(&cfg.Inputs[0].Server, bulker, f.cache) + at := api.NewArtifactT(&cfg.Inputs[0].Server, bulker, f.cache, pm) ack := api.NewAckT(&cfg.Inputs[0].Server, bulker, f.cache) st := api.NewStatusT(&cfg.Inputs[0].Server, bulker, f.cache, api.WithSelfMonitor(sm), api.WithBuildInfo(f.bi)) oa := api.NewOpAMPT(ctx, &cfg.Inputs[0].Server, bulker, f.cache, bc) From dee0b8646b51bb70a22126233e53848b79a921ea Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Mon, 11 May 2026 15:57:49 -0700 Subject: [PATCH 02/19] chore: add changelog fragment for artifact access control fix Co-Authored-By: Claude Opus 4.6 --- ...778540235-fix-artifact-access-control.yaml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 changelog/fragments/1778540235-fix-artifact-access-control.yaml diff --git a/changelog/fragments/1778540235-fix-artifact-access-control.yaml b/changelog/fragments/1778540235-fix-artifact-access-control.yaml new file mode 100644 index 0000000000..f6ef4aeb60 --- /dev/null +++ b/changelog/fragments/1778540235-fix-artifact-access-control.yaml @@ -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: security + +# Change summary; a 80ish characters long description of the change. +summary: Enforce policy-based access control on artifact downloads + +# 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: + +# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc. +component: fleet-server + +# 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/owner/repo/1234 From 395413746f23af95df67a1a6effbd720167a73c8 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Mon, 11 May 2026 16:08:50 -0700 Subject: [PATCH 03/19] docs: document race condition tradeoffs in authorizeArtifact Co-Authored-By: Claude Opus 4.6 --- internal/pkg/api/handleArtifacts.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index 0d2bca6ac4..f6358c9943 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -143,6 +143,19 @@ func (at ArtifactT) processRequest(ctx context.Context, zlog zerolog.Logger, age return rdr, nil } +// authorizeArtifact checks that the requested artifact is listed in the agent's +// assigned policy. The policy is read from the in-memory cache maintained by the +// policy monitor, which introduces a staleness window between ES updates and +// cache refresh. This creates two race conditions: +// +// - False negative (deny when should allow): an artifact was just added to a +// policy but the cache hasn't refreshed yet. This is self-healing — the agent +// will retry and succeed once the cache catches up. +// +// - False positive (allow when should deny): an artifact was just removed from +// a policy but the stale cache still lists it. The agent can download the +// artifact until the cache refreshes. This is the security-sensitive direction. +// A future improvement could verify against ES when the cache says "allow". func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, id, sha2 string) error { span, ctx := apm.StartSpan(ctx, "authorizeArtifacts", "auth") defer span.End() From 2dc613a043c7eef1f050fdcf1f09c2dbe19b5fc7 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Tue, 12 May 2026 05:49:50 -0700 Subject: [PATCH 04/19] fix: use any instead of interface{} per Go conventions Co-Authored-By: Claude Opus 4.6 --- internal/pkg/api/handleArtifacts.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index f6358c9943..187b9a9d44 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -182,7 +182,7 @@ func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { if !ok { continue } - amMap, ok := am.(map[string]interface{}) + amMap, ok := am.(map[string]any) if !ok { continue } @@ -190,7 +190,7 @@ func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { if !ok { continue } - artifactsMap, ok := artifacts.(map[string]interface{}) + artifactsMap, ok := artifacts.(map[string]any) if !ok { continue } @@ -198,7 +198,7 @@ func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { if !ok { continue } - artMap, ok := artEntry.(map[string]interface{}) + artMap, ok := artEntry.(map[string]any) if !ok { continue } From ed81475bf0c8a87a05c1c48741479ef32ffc5808 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Tue, 12 May 2026 05:57:22 -0700 Subject: [PATCH 05/19] refactor: add typed ArtifactManifest struct for policy input parsing Defines model.ArtifactManifest and model.ManifestEntry structs so policyHasArtifact no longer navigates untyped map[string]any chains. Co-Authored-By: Claude Opus 4.6 --- internal/pkg/api/handleArtifacts.go | 42 +++++++++++------------- internal/pkg/api/handleArtifacts_test.go | 26 +++++++-------- internal/pkg/model/schema.go | 18 ++++++++++ 3 files changed, 50 insertions(+), 36 deletions(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index 187b9a9d44..2b0c7d970a 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -178,37 +178,33 @@ func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, i func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { for _, input := range pd.Inputs { - am, ok := input["artifact_manifest"] - if !ok { + manifest, err := parseArtifactManifest(input) + if err != nil || manifest == nil { continue } - amMap, ok := am.(map[string]any) - if !ok { - continue - } - artifacts, ok := amMap["artifacts"] - if !ok { - continue - } - artifactsMap, ok := artifacts.(map[string]any) - if !ok { - continue - } - artEntry, ok := artifactsMap[id] - if !ok { - continue - } - artMap, ok := artEntry.(map[string]any) - if !ok { - continue - } - if ds, ok := artMap["decoded_sha256"].(string); ok && ds == sha2 { + if entry, ok := manifest.Artifacts[id]; ok && entry.DecodedSha256 == sha2 { return true } } return false } +func parseArtifactManifest(input map[string]any) (*model.ArtifactManifest, error) { + raw, ok := input["artifact_manifest"] + if !ok { + return nil, nil + } + data, err := json.Marshal(raw) + if err != nil { + return nil, err + } + var manifest model.ArtifactManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, err + } + return &manifest, nil +} + // Return artifact from cache by sha2 or fetch directly from Elastic. // Update cache on successful retrieval from Elastic. func (at ArtifactT) getArtifact(ctx context.Context, zlog zerolog.Logger, ident, sha2 string) (*model.Artifact, error) { diff --git a/internal/pkg/api/handleArtifacts_test.go b/internal/pkg/api/handleArtifacts_test.go index 9a455f294d..5642abb4a5 100644 --- a/internal/pkg/api/handleArtifacts_test.go +++ b/internal/pkg/api/handleArtifacts_test.go @@ -13,7 +13,7 @@ import ( func TestPolicyHasArtifact(t *testing.T) { policyData := &model.PolicyData{ - Inputs: []map[string]interface{}{ + Inputs: []map[string]any{ { "type": "logfile", "id": "logfile-1", @@ -21,15 +21,15 @@ func TestPolicyHasArtifact(t *testing.T) { { "type": "endpoint", "id": "endpoint-1", - "artifact_manifest": map[string]interface{}{ + "artifact_manifest": map[string]any{ "manifest_version": "1.0.28", "schema_version": "v1", - "artifacts": map[string]interface{}{ - "endpoint-trustlist-windows-v1": map[string]interface{}{ + "artifacts": map[string]any{ + "endpoint-trustlist-windows-v1": map[string]any{ "decoded_sha256": "74c2255ce31e0b48ada298ed6dacf6d1be7b0fb40c1bcb251d2da66f4b060acf", "decoded_size": float64(338), }, - "endpoint-trustlist-linux-v1": map[string]interface{}{ + "endpoint-trustlist-linux-v1": map[string]any{ "decoded_sha256": "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", "decoded_size": float64(14), }, @@ -92,7 +92,7 @@ func TestPolicyHasArtifact_NoInputs(t *testing.T) { func TestPolicyHasArtifact_NoArtifactManifest(t *testing.T) { pd := &model.PolicyData{ - Inputs: []map[string]interface{}{ + Inputs: []map[string]any{ {"type": "logfile"}, }, } @@ -101,13 +101,13 @@ func TestPolicyHasArtifact_NoArtifactManifest(t *testing.T) { func TestPolicyHasArtifact_MultipleInputsWithArtifacts(t *testing.T) { pd := &model.PolicyData{ - Inputs: []map[string]interface{}{ + Inputs: []map[string]any{ {"type": "logfile"}, { "type": "endpoint", - "artifact_manifest": map[string]interface{}{ - "artifacts": map[string]interface{}{ - "endpoint-trustlist-linux-v1": map[string]interface{}{ + "artifact_manifest": map[string]any{ + "artifacts": map[string]any{ + "endpoint-trustlist-linux-v1": map[string]any{ "decoded_sha256": "aaaa", }, }, @@ -115,9 +115,9 @@ func TestPolicyHasArtifact_MultipleInputsWithArtifacts(t *testing.T) { }, { "type": "another-endpoint", - "artifact_manifest": map[string]interface{}{ - "artifacts": map[string]interface{}{ - "endpoint-blocklist-linux-v1": map[string]interface{}{ + "artifact_manifest": map[string]any{ + "artifacts": map[string]any{ + "endpoint-blocklist-linux-v1": map[string]any{ "decoded_sha256": "bbbb", }, }, diff --git a/internal/pkg/model/schema.go b/internal/pkg/model/schema.go index 193509feff..96323d3c25 100644 --- a/internal/pkg/model/schema.go +++ b/internal/pkg/model/schema.go @@ -310,6 +310,24 @@ type Artifact struct { PackageName string `json:"package_name,omitempty"` } +// ArtifactManifest represents the artifact_manifest field within a policy input. +type ArtifactManifest struct { + ManifestVersion string `json:"manifest_version"` + SchemaVersion string `json:"schema_version"` + Artifacts map[string]ManifestEntry `json:"artifacts"` +} + +// ManifestEntry represents a single artifact entry within an artifact manifest. +type ManifestEntry struct { + DecodedSha256 string `json:"decoded_sha256"` + DecodedSize int64 `json:"decoded_size"` + EncodedSha256 string `json:"encoded_sha256"` + EncodedSize int64 `json:"encoded_size"` + RelativeURL string `json:"relative_url"` + Compression string `json:"compression_algorithm"` + Encryption string `json:"encryption_algorithm"` +} + // AvailableRollback type AvailableRollback struct { ValidUntil string `json:"valid_until,omitempty"` From ade69c17c4ef3496de424c1ac0a6af879e430c60 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Tue, 12 May 2026 16:17:05 -0700 Subject: [PATCH 06/19] fix: move ArtifactManifest types to non-generated model file schema.go is code-generated and gets overwritten by mage generate. Moving ArtifactManifest and ManifestEntry to ext.go keeps them stable. Co-Authored-By: Claude Opus 4.6 --- internal/pkg/model/ext.go | 18 ++++++++++++++++++ internal/pkg/model/schema.go | 18 ------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/pkg/model/ext.go b/internal/pkg/model/ext.go index c342183c52..60c14f0378 100644 --- a/internal/pkg/model/ext.go +++ b/internal/pkg/model/ext.go @@ -133,6 +133,24 @@ func cloneOTelService(s *Service) *Service { return &clone } +// ArtifactManifest represents the artifact_manifest field within a policy input. +type ArtifactManifest struct { + ManifestVersion string `json:"manifest_version"` + SchemaVersion string `json:"schema_version"` + Artifacts map[string]ManifestEntry `json:"artifacts"` +} + +// ManifestEntry represents a single artifact entry within an artifact manifest. +type ManifestEntry struct { + DecodedSha256 string `json:"decoded_sha256"` + DecodedSize int64 `json:"decoded_size"` + EncodedSha256 string `json:"encoded_sha256"` + EncodedSize int64 `json:"encoded_size"` + RelativeURL string `json:"relative_url"` + Compression string `json:"compression_algorithm"` + Encryption string `json:"encryption_algorithm"` +} + // cloneMap does a deep copy on a map of objects // TODO generics? func cloneMap(m map[string]map[string]any) map[string]map[string]any { diff --git a/internal/pkg/model/schema.go b/internal/pkg/model/schema.go index 96323d3c25..193509feff 100644 --- a/internal/pkg/model/schema.go +++ b/internal/pkg/model/schema.go @@ -310,24 +310,6 @@ type Artifact struct { PackageName string `json:"package_name,omitempty"` } -// ArtifactManifest represents the artifact_manifest field within a policy input. -type ArtifactManifest struct { - ManifestVersion string `json:"manifest_version"` - SchemaVersion string `json:"schema_version"` - Artifacts map[string]ManifestEntry `json:"artifacts"` -} - -// ManifestEntry represents a single artifact entry within an artifact manifest. -type ManifestEntry struct { - DecodedSha256 string `json:"decoded_sha256"` - DecodedSize int64 `json:"decoded_size"` - EncodedSha256 string `json:"encoded_sha256"` - EncodedSize int64 `json:"encoded_size"` - RelativeURL string `json:"relative_url"` - Compression string `json:"compression_algorithm"` - Encryption string `json:"encryption_algorithm"` -} - // AvailableRollback type AvailableRollback struct { ValidUntil string `json:"valid_until,omitempty"` From e805edc8f44e9b87f45793584ccdfb49ef3e4488 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Tue, 12 May 2026 17:03:30 -0700 Subject: [PATCH 07/19] refactor: move artifact manifest types to handleArtifacts.go ArtifactManifest and ManifestEntry are not ES document types and only exist to support parsing within handleArtifacts.go, so they belong there rather than in the model package. Co-Authored-By: Claude Opus 4.6 --- internal/pkg/api/handleArtifacts.go | 12 ++++++++++-- internal/pkg/model/ext.go | 18 ------------------ 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index 2b0c7d970a..6a156d12ac 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -176,6 +176,14 @@ func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, i return ErrUnauthorizedArtifact } +type artifactManifest struct { + Artifacts map[string]manifestEntry `json:"artifacts"` +} + +type manifestEntry struct { + DecodedSha256 string `json:"decoded_sha256"` +} + func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { for _, input := range pd.Inputs { manifest, err := parseArtifactManifest(input) @@ -189,7 +197,7 @@ func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { return false } -func parseArtifactManifest(input map[string]any) (*model.ArtifactManifest, error) { +func parseArtifactManifest(input map[string]any) (*artifactManifest, error) { raw, ok := input["artifact_manifest"] if !ok { return nil, nil @@ -198,7 +206,7 @@ func parseArtifactManifest(input map[string]any) (*model.ArtifactManifest, error if err != nil { return nil, err } - var manifest model.ArtifactManifest + var manifest artifactManifest if err := json.Unmarshal(data, &manifest); err != nil { return nil, err } diff --git a/internal/pkg/model/ext.go b/internal/pkg/model/ext.go index 60c14f0378..c342183c52 100644 --- a/internal/pkg/model/ext.go +++ b/internal/pkg/model/ext.go @@ -133,24 +133,6 @@ func cloneOTelService(s *Service) *Service { return &clone } -// ArtifactManifest represents the artifact_manifest field within a policy input. -type ArtifactManifest struct { - ManifestVersion string `json:"manifest_version"` - SchemaVersion string `json:"schema_version"` - Artifacts map[string]ManifestEntry `json:"artifacts"` -} - -// ManifestEntry represents a single artifact entry within an artifact manifest. -type ManifestEntry struct { - DecodedSha256 string `json:"decoded_sha256"` - DecodedSize int64 `json:"decoded_size"` - EncodedSha256 string `json:"encoded_sha256"` - EncodedSize int64 `json:"encoded_size"` - RelativeURL string `json:"relative_url"` - Compression string `json:"compression_algorithm"` - Encryption string `json:"encryption_algorithm"` -} - // cloneMap does a deep copy on a map of objects // TODO generics? func cloneMap(m map[string]map[string]any) map[string]map[string]any { From 6485328f977c2f54aa413aaf2e9ad562d85256fb Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Wed, 13 May 2026 18:13:25 -0700 Subject: [PATCH 08/19] test(e2e): fix TestArtifact to use security-policy with Elastic Defend Agents enrolled under dummy-policy cannot download Elastic Defend artifacts because that policy has no artifact_manifest. Enroll the test agent under security-policy (which has the Elastic Defend integration) instead. Add FleetPolicyHasArtifact scaffold helper that polls .fleet-policies until the policy document references the artifact, ensuring fleet-server's policy monitor cache is up-to-date before the download attempt. Also retry the download on 403 to tolerate any remaining cache propagation lag. Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/api_version/client_api_2023_06_01.go | 35 ++++++++- testing/e2e/api_version/client_api_current.go | 35 ++++++++- testing/e2e/scaffold/scaffold.go | 76 +++++++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/testing/e2e/api_version/client_api_2023_06_01.go b/testing/e2e/api_version/client_api_2023_06_01.go index 5dd7c97d3d..3ca46992bb 100644 --- a/testing/e2e/api_version/client_api_2023_06_01.go +++ b/testing/e2e/api_version/client_api_2023_06_01.go @@ -308,10 +308,41 @@ func (tester *ClientAPITester20230601) TestArtifact() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - _, agentKey := tester.Enroll(ctx, tester.enrollmentKey) + // Elastic Defend (endpoint) artifacts are only authorized for agents enrolled under a + // policy that has the Elastic Defend integration (security-policy). dummy-policy has no + // Elastic Defend input and therefore no artifact_manifest, so agents enrolled there + // would get 403 when downloading endpoint artifacts. + securityPolicyKey := tester.GetEnrollmentTokenForPolicyID(ctx, "security-policy") + _, agentKey := tester.Enroll(ctx, securityPolicyKey) tester.AddSecurityContainer(ctx) tester.AddSecurityContainerItem(ctx) hits := tester.FleetHasArtifacts(ctx) - tester.Artifact(ctx, agentKey, hits[0].Source.Identifier, hits[0].Source.DecodedSHA256, hits[0].Source.EncodedSHA256) + id, sha2, encodedSHA := hits[0].Source.Identifier, hits[0].Source.DecodedSHA256, hits[0].Source.EncodedSHA256 + // Wait for the policy document in ES to reference the artifact. This also gives the + // fleet-server policy monitor time to refresh its cache before we attempt the download. + tester.FleetPolicyHasArtifact(ctx, "security-policy", id, sha2) + // Retry on 403: even after the ES policy is updated, the in-memory cache in fleet-server + // may not have caught up yet. The monitor refreshes quickly but retrying is more robust. + apiClient, err := tester.getAPIClient(func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "ApiKey "+agentKey) + return nil + }) + tester.Require().NoError(err) + for { + resp, err := apiClient.ArtifactWithResponse(ctx, id, sha2, &api.ArtifactParams{}) + tester.Require().NoError(err) + if resp.StatusCode() == http.StatusForbidden { + select { + case <-ctx.Done(): + tester.Require().NoError(ctx.Err(), "context expired while retrying artifact download") + case <-time.After(time.Second): + } + continue + } + tester.Require().Equal(http.StatusOK, resp.StatusCode()) + hash := sha256.Sum256(resp.Body) + tester.Require().Equal(encodedSHA, fmt.Sprintf("%x", hash[:])) + break + } } diff --git a/testing/e2e/api_version/client_api_current.go b/testing/e2e/api_version/client_api_current.go index 4f6c587b6a..afee20f6f7 100644 --- a/testing/e2e/api_version/client_api_current.go +++ b/testing/e2e/api_version/client_api_current.go @@ -408,12 +408,43 @@ func (tester *ClientAPITester) TestArtifact() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - _, agentKey := tester.Enroll(ctx, tester.enrollmentKey) + // Elastic Defend (endpoint) artifacts are only authorized for agents enrolled under a + // policy that has the Elastic Defend integration (security-policy). dummy-policy has no + // Elastic Defend input and therefore no artifact_manifest, so agents enrolled there + // would get 403 when downloading endpoint artifacts. + securityPolicyKey := tester.GetEnrollmentTokenForPolicyID(ctx, "security-policy") + _, agentKey := tester.Enroll(ctx, securityPolicyKey) tester.AddSecurityContainer(ctx) tester.AddSecurityContainerItem(ctx) hits := tester.FleetHasArtifacts(ctx) - tester.Artifact(ctx, agentKey, hits[0].Source.Identifier, hits[0].Source.DecodedSHA256, hits[0].Source.EncodedSHA256) + id, sha2, encodedSHA := hits[0].Source.Identifier, hits[0].Source.DecodedSHA256, hits[0].Source.EncodedSHA256 + // Wait for the policy document in ES to reference the artifact. This also gives the + // fleet-server policy monitor time to refresh its cache before we attempt the download. + tester.FleetPolicyHasArtifact(ctx, "security-policy", id, sha2) + // Retry on 403: even after the ES policy is updated, the in-memory cache in fleet-server + // may not have caught up yet. The monitor refreshes quickly but retrying is more robust. + for { + client, err := api.NewClientWithResponses(tester.endpoint, api.WithHTTPClient(tester.Client), api.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "ApiKey "+agentKey) + return nil + })) + tester.Require().NoError(err) + resp, err := client.ArtifactWithResponse(ctx, id, sha2, &api.ArtifactParams{}) + tester.Require().NoError(err) + if resp.StatusCode() == http.StatusForbidden { + select { + case <-ctx.Done(): + tester.Require().NoError(ctx.Err(), "context expired while retrying artifact download") + case <-time.After(time.Second): + } + continue + } + tester.Require().Equal(http.StatusOK, resp.StatusCode()) + hash := sha256.Sum256(resp.Body) + tester.Require().Equal(encodedSHA, fmt.Sprintf("%x", hash[:])) + break + } } func (tester *ClientAPITester) TestGetPGPKey() { diff --git a/testing/e2e/scaffold/scaffold.go b/testing/e2e/scaffold/scaffold.go index 4e214fc20c..636235c8b5 100644 --- a/testing/e2e/scaffold/scaffold.go +++ b/testing/e2e/scaffold/scaffold.go @@ -592,6 +592,82 @@ func (s *Scaffold) FleetHasArtifacts(ctx context.Context) []ArtifactHit { } } +// FleetPolicyHasArtifact polls .fleet-policies until the most recent revision for the +// given policyID has the specified artifact (by identifier and decoded SHA256) in at +// least one input's artifact_manifest. This ensures the policy monitor will have +// up-to-date data before the caller attempts an artifact download. +func (s *Scaffold) FleetPolicyHasArtifact(ctx context.Context, policyID, identifier, decodedSha256 string) { + timer := time.NewTimer(time.Second) + for { + query := fmt.Sprintf(`{"query":{"term":{"policy_id":"%s"}},"sort":[{"revision_idx":{"order":"desc"}}],"size":1}`, policyID) + req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost:9200/.fleet-policies/_search", strings.NewReader(query)) + s.Require().NoError(err) + req.SetBasicAuth(s.ElasticUser, s.ElasticPass) + req.Header.Set("Content-Type", "application/json") + select { + case <-ctx.Done(): + s.Require().NoError(ctx.Err(), "context expired before policy artifact_manifest was updated") + return + case <-timer.C: + resp, err := s.Client.Do(req) + s.Require().NoError(err) + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + timer.Reset(time.Second) + continue + } + + var result struct { + Hits struct { + Hits []struct { + Source struct { + Data struct { + Inputs []map[string]any `json:"inputs"` + } `json:"data"` + } `json:"_source"` + } `json:"hits"` + Total struct { + Value int `json:"value"` + } `json:"total"` + } `json:"hits"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + resp.Body.Close() + s.Require().NoError(err) + + if result.Hits.Total.Value > 0 { + for _, input := range result.Hits.Hits[0].Source.Data.Inputs { + if policyInputHasArtifact(input, identifier, decodedSha256) { + return + } + } + } + timer.Reset(time.Second) + } + } +} + +func policyInputHasArtifact(input map[string]any, identifier, decodedSha256 string) bool { + raw, ok := input["artifact_manifest"] + if !ok { + return false + } + data, err := json.Marshal(raw) + if err != nil { + return false + } + var manifest struct { + Artifacts map[string]struct { + DecodedSha256 string `json:"decoded_sha256"` + } `json:"artifacts"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + return false + } + entry, ok := manifest.Artifacts[identifier] + return ok && entry.DecodedSha256 == decodedSha256 +} + type logger struct { *testing.T } From 46f209edd0a95a1f08d6d5abc7304129872828ff Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Fri, 15 May 2026 06:42:42 -0700 Subject: [PATCH 09/19] refactor: use type assertions instead of JSON round-trip in artifact checks PolicyData.Inputs is already decoded as []map[string]any, so marshaling back to JSON just to unmarshal again is unnecessary. Use type assertions directly on the decoded map in both policyHasArtifact (production) and policyInputHasArtifact (e2e scaffold), removing the artifactManifest and manifestEntry types along with parseArtifactManifest. Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts.go | 38 +++++++++-------------------- testing/e2e/scaffold/scaffold.go | 18 ++++++-------- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index 6a156d12ac..ff3f639250 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -176,43 +176,27 @@ func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, i return ErrUnauthorizedArtifact } -type artifactManifest struct { - Artifacts map[string]manifestEntry `json:"artifacts"` -} - -type manifestEntry struct { - DecodedSha256 string `json:"decoded_sha256"` -} - func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { for _, input := range pd.Inputs { - manifest, err := parseArtifactManifest(input) - if err != nil || manifest == nil { + manifestRaw, ok := input["artifact_manifest"].(map[string]any) + if !ok { + continue + } + artifacts, ok := manifestRaw["artifacts"].(map[string]any) + if !ok { + continue + } + entry, ok := artifacts[id].(map[string]any) + if !ok { continue } - if entry, ok := manifest.Artifacts[id]; ok && entry.DecodedSha256 == sha2 { + if sha, _ := entry["decoded_sha256"].(string); sha == sha2 { return true } } return false } -func parseArtifactManifest(input map[string]any) (*artifactManifest, error) { - raw, ok := input["artifact_manifest"] - if !ok { - return nil, nil - } - data, err := json.Marshal(raw) - if err != nil { - return nil, err - } - var manifest artifactManifest - if err := json.Unmarshal(data, &manifest); err != nil { - return nil, err - } - return &manifest, nil -} - // Return artifact from cache by sha2 or fetch directly from Elastic. // Update cache on successful retrieval from Elastic. func (at ArtifactT) getArtifact(ctx context.Context, zlog zerolog.Logger, ident, sha2 string) (*model.Artifact, error) { diff --git a/testing/e2e/scaffold/scaffold.go b/testing/e2e/scaffold/scaffold.go index 636235c8b5..fbed14eae0 100644 --- a/testing/e2e/scaffold/scaffold.go +++ b/testing/e2e/scaffold/scaffold.go @@ -648,24 +648,20 @@ func (s *Scaffold) FleetPolicyHasArtifact(ctx context.Context, policyID, identif } func policyInputHasArtifact(input map[string]any, identifier, decodedSha256 string) bool { - raw, ok := input["artifact_manifest"] + manifestRaw, ok := input["artifact_manifest"].(map[string]any) if !ok { return false } - data, err := json.Marshal(raw) - if err != nil { + artifacts, ok := manifestRaw["artifacts"].(map[string]any) + if !ok { return false } - var manifest struct { - Artifacts map[string]struct { - DecodedSha256 string `json:"decoded_sha256"` - } `json:"artifacts"` - } - if err := json.Unmarshal(data, &manifest); err != nil { + entry, ok := artifacts[identifier].(map[string]any) + if !ok { return false } - entry, ok := manifest.Artifacts[identifier] - return ok && entry.DecodedSha256 == decodedSha256 + sha, _ := entry["decoded_sha256"].(string) + return sha == decodedSha256 } type logger struct { From 0d1b1b06d5a159d7d6a1a1117db3237336ce918f Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Fri, 15 May 2026 12:23:24 -0700 Subject: [PATCH 10/19] test(e2e): increase TestArtifact timeout and guard against expired context The ES global checkpoint monitor can hold up to 4 minutes before the policy cache refreshes. On slow CI the setup steps (FleetHasArtifacts + FleetPolicyHasArtifact) could exhaust the 3-minute budget, leaving the retry loop to fail with a misleading "context deadline exceeded" from the HTTP call. Raise the budget to 5 minutes and add an explicit ctx.Err() check at the top of the retry loop so expiry surfaces a clear message. Co-Authored-By: Claude Sonnet 4.6 --- testing/e2e/api_version/client_api_2023_06_01.go | 5 ++++- testing/e2e/api_version/client_api_current.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/testing/e2e/api_version/client_api_2023_06_01.go b/testing/e2e/api_version/client_api_2023_06_01.go index 3ca46992bb..55a65ea675 100644 --- a/testing/e2e/api_version/client_api_2023_06_01.go +++ b/testing/e2e/api_version/client_api_2023_06_01.go @@ -305,7 +305,7 @@ func (tester *ClientAPITester20230601) TestFullFileUpload() { } func (tester *ClientAPITester20230601) TestArtifact() { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Elastic Defend (endpoint) artifacts are only authorized for agents enrolled under a @@ -330,6 +330,9 @@ func (tester *ClientAPITester20230601) TestArtifact() { }) tester.Require().NoError(err) for { + if err := ctx.Err(); err != nil { + tester.Require().NoError(err, "context expired before artifact download succeeded") + } resp, err := apiClient.ArtifactWithResponse(ctx, id, sha2, &api.ArtifactParams{}) tester.Require().NoError(err) if resp.StatusCode() == http.StatusForbidden { diff --git a/testing/e2e/api_version/client_api_current.go b/testing/e2e/api_version/client_api_current.go index afee20f6f7..9ecbddee28 100644 --- a/testing/e2e/api_version/client_api_current.go +++ b/testing/e2e/api_version/client_api_current.go @@ -405,7 +405,7 @@ func (tester *ClientAPITester) TestFullFileUpload() { } func (tester *ClientAPITester) TestArtifact() { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Elastic Defend (endpoint) artifacts are only authorized for agents enrolled under a @@ -425,6 +425,9 @@ func (tester *ClientAPITester) TestArtifact() { // Retry on 403: even after the ES policy is updated, the in-memory cache in fleet-server // may not have caught up yet. The monitor refreshes quickly but retrying is more robust. for { + if err := ctx.Err(); err != nil { + tester.Require().NoError(err, "context expired before artifact download succeeded") + } client, err := api.NewClientWithResponses(tester.endpoint, api.WithHTTPClient(tester.Client), api.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { req.Header.Set("Authorization", "ApiKey "+agentKey) return nil From a205c52a52b8871ae772184d078c20f7e84ea744 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Wed, 20 May 2026 18:39:50 -0700 Subject: [PATCH 11/19] fix: update license header to Elastic License 2.0 Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/pkg/api/handleArtifacts_test.go b/internal/pkg/api/handleArtifacts_test.go index 5642abb4a5..362332c4f2 100644 --- a/internal/pkg/api/handleArtifacts_test.go +++ b/internal/pkg/api/handleArtifacts_test.go @@ -1,6 +1,6 @@ // Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -// or more contributor license agreements. Licensed under the Elastic License; -// you may not use this file except in compliance with the Elastic License. +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. package api From 66adb280d7ece64935fcf910b4597312b8771e5c Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 21 May 2026 05:35:39 -0700 Subject: [PATCH 12/19] fix(e2e): use tester.T().Context() instead of context.Background() Co-Authored-By: Claude Sonnet 4.6 --- testing/e2e/api_version/client_api_2023_06_01.go | 2 +- testing/e2e/api_version/client_api_current.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/e2e/api_version/client_api_2023_06_01.go b/testing/e2e/api_version/client_api_2023_06_01.go index 55a65ea675..1164ab022d 100644 --- a/testing/e2e/api_version/client_api_2023_06_01.go +++ b/testing/e2e/api_version/client_api_2023_06_01.go @@ -305,7 +305,7 @@ func (tester *ClientAPITester20230601) TestFullFileUpload() { } func (tester *ClientAPITester20230601) TestArtifact() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(tester.T().Context(), 5*time.Minute) defer cancel() // Elastic Defend (endpoint) artifacts are only authorized for agents enrolled under a diff --git a/testing/e2e/api_version/client_api_current.go b/testing/e2e/api_version/client_api_current.go index 9ecbddee28..3b439680ac 100644 --- a/testing/e2e/api_version/client_api_current.go +++ b/testing/e2e/api_version/client_api_current.go @@ -405,7 +405,7 @@ func (tester *ClientAPITester) TestFullFileUpload() { } func (tester *ClientAPITester) TestArtifact() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(tester.T().Context(), 5*time.Minute) defer cancel() // Elastic Defend (endpoint) artifacts are only authorized for agents enrolled under a From a031849e4b749b93951b90f26600793f30681f57 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 4 Jun 2026 06:07:00 -0700 Subject: [PATCH 13/19] refactor: extract inputHasArtifact helper from policyHasArtifact Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts.go | 31 +++++++++++++++++------------ testing/e2e/scaffold/scaffold.go | 4 ++-- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index ff3f639250..a7068712c6 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -178,25 +178,30 @@ func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, i func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { for _, input := range pd.Inputs { - manifestRaw, ok := input["artifact_manifest"].(map[string]any) - if !ok { - continue - } - artifacts, ok := manifestRaw["artifacts"].(map[string]any) - if !ok { - continue - } - entry, ok := artifacts[id].(map[string]any) - if !ok { - continue - } - if sha, _ := entry["decoded_sha256"].(string); sha == sha2 { + if inputHasArtifact(input, id, sha2) { return true } } return false } +func inputHasArtifact(input map[string]any, id, sha2 string) bool { + manifestRaw, ok := input["artifact_manifest"].(map[string]any) + if !ok { + return false + } + artifacts, ok := manifestRaw["artifacts"].(map[string]any) + if !ok { + return false + } + entry, ok := artifacts[id].(map[string]any) + if !ok { + return false + } + sha, _ := entry["decoded_sha256"].(string) + return sha == sha2 +} + // Return artifact from cache by sha2 or fetch directly from Elastic. // Update cache on successful retrieval from Elastic. func (at ArtifactT) getArtifact(ctx context.Context, zlog zerolog.Logger, ident, sha2 string) (*model.Artifact, error) { diff --git a/testing/e2e/scaffold/scaffold.go b/testing/e2e/scaffold/scaffold.go index fbed14eae0..cf6d68f5f1 100644 --- a/testing/e2e/scaffold/scaffold.go +++ b/testing/e2e/scaffold/scaffold.go @@ -637,7 +637,7 @@ func (s *Scaffold) FleetPolicyHasArtifact(ctx context.Context, policyID, identif if result.Hits.Total.Value > 0 { for _, input := range result.Hits.Hits[0].Source.Data.Inputs { - if policyInputHasArtifact(input, identifier, decodedSha256) { + if inputHasArtifact(input, identifier, decodedSha256) { return } } @@ -647,7 +647,7 @@ func (s *Scaffold) FleetPolicyHasArtifact(ctx context.Context, policyID, identif } } -func policyInputHasArtifact(input map[string]any, identifier, decodedSha256 string) bool { +func inputHasArtifact(input map[string]any, identifier, decodedSha256 string) bool { manifestRaw, ok := input["artifact_manifest"].(map[string]any) if !ok { return false From 4a41e0e8394853b681f2ce8c7e54e6e955bbd3f8 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 4 Jun 2026 06:07:42 -0700 Subject: [PATCH 14/19] refactor: use dl field constants for artifact manifest keys Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts.go | 6 +++--- internal/pkg/dl/constants.go | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index a7068712c6..414c902a68 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -186,11 +186,11 @@ func policyHasArtifact(pd *model.PolicyData, id, sha2 string) bool { } func inputHasArtifact(input map[string]any, id, sha2 string) bool { - manifestRaw, ok := input["artifact_manifest"].(map[string]any) + manifestRaw, ok := input[dl.FieldArtifactManifest].(map[string]any) if !ok { return false } - artifacts, ok := manifestRaw["artifacts"].(map[string]any) + artifacts, ok := manifestRaw[dl.FieldArtifacts].(map[string]any) if !ok { return false } @@ -198,7 +198,7 @@ func inputHasArtifact(input map[string]any, id, sha2 string) bool { if !ok { return false } - sha, _ := entry["decoded_sha256"].(string) + sha, _ := entry[dl.FieldDecodedSha256].(string) return sha == sha2 } diff --git a/internal/pkg/dl/constants.go b/internal/pkg/dl/constants.go index 541efd2e94..9d1cae4f9c 100644 --- a/internal/pkg/dl/constants.go +++ b/internal/pkg/dl/constants.go @@ -65,7 +65,9 @@ const ( FieldAuditUnenrolledTime = "audit_unenrolled_time" FieldAuditUnenrolledReason = "audit_unenrolled_reason" - FieldDecodedSha256 = "decoded_sha256" + FieldArtifactManifest = "artifact_manifest" + FieldArtifacts = "artifacts" + FieldDecodedSha256 = "decoded_sha256" FieldIdentifier = "identifier" FieldSharedID = "shared_id" FieldEnrollmentID = "enrollment_id" From c21c94186e74b799213c4b7e6e75ec6c6052c4cd Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 4 Jun 2026 06:11:40 -0700 Subject: [PATCH 15/19] fix: return ErrUnauthorizedArtifact (403) when agent policy not found Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts.go | 3 + internal/pkg/api/handleArtifacts_test.go | 97 ++++++++++++++++++++++++ internal/pkg/policy/monitor.go | 4 +- 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index 414c902a68..eb50f3bac0 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -165,6 +165,9 @@ func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, i } p, err := at.pm.GetPolicy(ctx, agent.AgentPolicyID) + if errors.Is(err, policy.ErrPolicyNotFound) { + return ErrUnauthorizedArtifact + } if err != nil { return fmt.Errorf("authorizeArtifact: %w", err) } diff --git a/internal/pkg/api/handleArtifacts_test.go b/internal/pkg/api/handleArtifacts_test.go index 362332c4f2..24ec898e6d 100644 --- a/internal/pkg/api/handleArtifacts_test.go +++ b/internal/pkg/api/handleArtifacts_test.go @@ -5,10 +5,14 @@ package api import ( + "context" + "errors" "testing" "github.com/elastic/fleet-server/v7/internal/pkg/model" + "github.com/elastic/fleet-server/v7/internal/pkg/policy" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPolicyHasArtifact(t *testing.T) { @@ -129,3 +133,96 @@ func TestPolicyHasArtifact_MultipleInputsWithArtifacts(t *testing.T) { assert.True(t, policyHasArtifact(pd, "endpoint-blocklist-linux-v1", "bbbb")) assert.False(t, policyHasArtifact(pd, "endpoint-trustlist-linux-v1", "bbbb")) } + +func TestAuthorizeArtifact(t *testing.T) { + const ( + policyID = "test-policy-id" + artifactID = "endpoint-trustlist-linux-v1" + sha2 = "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658" + ) + + policyWithArtifact := &model.Policy{ + Data: &model.PolicyData{ + Inputs: []map[string]any{ + { + "type": "endpoint", + "artifact_manifest": map[string]any{ + "artifacts": map[string]any{ + artifactID: map[string]any{ + "decoded_sha256": sha2, + }, + }, + }, + }, + }, + }, + } + + tests := []struct { + name string + agent *model.Agent + setupMock func(pm *mockPolicyMonitor) + wantErr error + }{ + { + name: "authorized: artifact in policy", + agent: &model.Agent{AgentPolicyID: policyID}, + setupMock: func(pm *mockPolicyMonitor) { + pm.On("GetPolicy", context.Background(), policyID).Return(policyWithArtifact, nil) + }, + wantErr: nil, + }, + { + name: "unauthorized: artifact not in policy", + agent: &model.Agent{AgentPolicyID: policyID}, + setupMock: func(pm *mockPolicyMonitor) { + pm.On("GetPolicy", context.Background(), policyID).Return(&model.Policy{ + Data: &model.PolicyData{}, + }, nil) + }, + wantErr: ErrUnauthorizedArtifact, + }, + { + name: "unauthorized: policy not found maps to 403", + agent: &model.Agent{AgentPolicyID: policyID}, + setupMock: func(pm *mockPolicyMonitor) { + pm.On("GetPolicy", context.Background(), policyID).Return(nil, policy.ErrPolicyNotFound) + }, + wantErr: ErrUnauthorizedArtifact, + }, + { + name: "error: GetPolicy returns unexpected error", + agent: &model.Agent{AgentPolicyID: policyID}, + setupMock: func(pm *mockPolicyMonitor) { + pm.On("GetPolicy", context.Background(), policyID).Return(nil, errors.New("elasticsearch unavailable")) + }, + wantErr: nil, // wrapped, so we check IsUnauthorized is false + }, + { + name: "forbidden: agent has no policy ID", + agent: &model.Agent{}, + setupMock: func(pm *mockPolicyMonitor) {}, + wantErr: ErrAgentPolicyIDMissing, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm := &mockPolicyMonitor{} + tt.setupMock(pm) + at := ArtifactT{pm: pm} + + err := at.authorizeArtifact(context.Background(), tt.agent, artifactID, sha2) + + if tt.name == "error: GetPolicy returns unexpected error" { + require.Error(t, err) + assert.False(t, errors.Is(err, ErrUnauthorizedArtifact)) + } else if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + pm.AssertExpectations(t) + }) + } +} diff --git a/internal/pkg/policy/monitor.go b/internal/pkg/policy/monitor.go index d4681190fe..c38552f198 100644 --- a/internal/pkg/policy/monitor.go +++ b/internal/pkg/policy/monitor.go @@ -26,6 +26,8 @@ import ( const cloudPolicyID = "policy-elastic-agent-on-cloud" +var ErrPolicyNotFound = errors.New("policy not found") + /* Design should have the following properties @@ -621,7 +623,7 @@ func (m *monitorT) GetPolicy(ctx context.Context, policyID string) (*model.Polic m.mut.Unlock() if !ok { - return nil, fmt.Errorf("policy %s not found", policyID) + return nil, fmt.Errorf("%w: %s", ErrPolicyNotFound, policyID) } return &p.pp.Policy, nil } From 7c7cf4c35680cc1cd3c63e379fefde0ef2248f61 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 4 Jun 2026 06:17:33 -0700 Subject: [PATCH 16/19] fix(test): add !integration build tag to handleArtifacts_test.go Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/pkg/api/handleArtifacts_test.go b/internal/pkg/api/handleArtifacts_test.go index 24ec898e6d..2ffd7aa446 100644 --- a/internal/pkg/api/handleArtifacts_test.go +++ b/internal/pkg/api/handleArtifacts_test.go @@ -2,6 +2,8 @@ // or more contributor license agreements. Licensed under the Elastic License 2.0; // you may not use this file except in compliance with the Elastic License 2.0. +//go:build !integration + package api import ( @@ -15,6 +17,20 @@ import ( "github.com/stretchr/testify/require" ) +type stubPolicyMonitor struct { + getPolicy func(ctx context.Context, policyID string) (*model.Policy, error) +} + +func (s *stubPolicyMonitor) Run(_ context.Context) error { return nil } +func (s *stubPolicyMonitor) Subscribe(_, _ string, _ int64) (policy.Subscription, error) { + return nil, nil +} +func (s *stubPolicyMonitor) Unsubscribe(_ policy.Subscription) error { return nil } +func (s *stubPolicyMonitor) LatestRev(_ context.Context, _ string) int64 { return 0 } +func (s *stubPolicyMonitor) GetPolicy(ctx context.Context, policyID string) (*model.Policy, error) { + return s.getPolicy(ctx, policyID) +} + func TestPolicyHasArtifact(t *testing.T) { policyData := &model.PolicyData{ Inputs: []map[string]any{ From 5e5192b48916197bfe8cb45c8ea0178131c4f5a2 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 4 Jun 2026 06:22:09 -0700 Subject: [PATCH 17/19] fix: align dl constants after adding FieldArtifactManifest/FieldArtifacts Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/dl/constants.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/pkg/dl/constants.go b/internal/pkg/dl/constants.go index 9d1cae4f9c..5ca9dc27c4 100644 --- a/internal/pkg/dl/constants.go +++ b/internal/pkg/dl/constants.go @@ -68,9 +68,9 @@ const ( FieldArtifactManifest = "artifact_manifest" FieldArtifacts = "artifacts" FieldDecodedSha256 = "decoded_sha256" - FieldIdentifier = "identifier" - FieldSharedID = "shared_id" - FieldEnrollmentID = "enrollment_id" + FieldIdentifier = "identifier" + FieldSharedID = "shared_id" + FieldEnrollmentID = "enrollment_id" ) // Private constants From 50184e485a587066d5d3ed107e3b6c180a169048 Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 4 Jun 2026 06:42:03 -0700 Subject: [PATCH 18/19] fix(test): apply goimports formatting to handleArtifacts_test.go Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/pkg/api/handleArtifacts_test.go b/internal/pkg/api/handleArtifacts_test.go index 2ffd7aa446..7c158d915b 100644 --- a/internal/pkg/api/handleArtifacts_test.go +++ b/internal/pkg/api/handleArtifacts_test.go @@ -21,11 +21,11 @@ type stubPolicyMonitor struct { getPolicy func(ctx context.Context, policyID string) (*model.Policy, error) } -func (s *stubPolicyMonitor) Run(_ context.Context) error { return nil } +func (s *stubPolicyMonitor) Run(_ context.Context) error { return nil } func (s *stubPolicyMonitor) Subscribe(_, _ string, _ int64) (policy.Subscription, error) { return nil, nil } -func (s *stubPolicyMonitor) Unsubscribe(_ policy.Subscription) error { return nil } +func (s *stubPolicyMonitor) Unsubscribe(_ policy.Subscription) error { return nil } func (s *stubPolicyMonitor) LatestRev(_ context.Context, _ string) int64 { return 0 } func (s *stubPolicyMonitor) GetPolicy(ctx context.Context, policyID string) (*model.Policy, error) { return s.getPolicy(ctx, policyID) @@ -152,7 +152,7 @@ func TestPolicyHasArtifact_MultipleInputsWithArtifacts(t *testing.T) { func TestAuthorizeArtifact(t *testing.T) { const ( - policyID = "test-policy-id" + policyID = "test-policy-id" artifactID = "endpoint-trustlist-linux-v1" sha2 = "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658" ) @@ -215,10 +215,10 @@ func TestAuthorizeArtifact(t *testing.T) { wantErr: nil, // wrapped, so we check IsUnauthorized is false }, { - name: "forbidden: agent has no policy ID", - agent: &model.Agent{}, + name: "forbidden: agent has no policy ID", + agent: &model.Agent{}, setupMock: func(pm *mockPolicyMonitor) {}, - wantErr: ErrAgentPolicyIDMissing, + wantErr: ErrAgentPolicyIDMissing, }, } From 04c5dace9d6c62071027c8a6c4957d6a4a36e2de Mon Sep 17 00:00:00 2001 From: Shaunak Kashyap Date: Thu, 4 Jun 2026 10:25:00 -0700 Subject: [PATCH 19/19] fix: fall back to policy_id for pre-checkin agents in authorizeArtifact agent_policy_id is only populated after first checkin; policy_id is set at enrollment. Use policy_id as fallback so newly enrolled agents can download artifacts before their first checkin. Co-Authored-By: Claude Sonnet 4.6 --- internal/pkg/api/handleArtifacts.go | 11 +++++++++-- internal/pkg/api/handleArtifacts_test.go | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/pkg/api/handleArtifacts.go b/internal/pkg/api/handleArtifacts.go index eb50f3bac0..d0b22a60e1 100644 --- a/internal/pkg/api/handleArtifacts.go +++ b/internal/pkg/api/handleArtifacts.go @@ -160,11 +160,18 @@ func (at ArtifactT) authorizeArtifact(ctx context.Context, agent *model.Agent, i span, ctx := apm.StartSpan(ctx, "authorizeArtifacts", "auth") defer span.End() - if agent.AgentPolicyID == "" { + // AgentPolicyID (agent_policy_id) is set at first checkin; PolicyID (policy_id) is set + // at enrollment. Fall back to PolicyID so newly enrolled agents that have not yet + // checked in can still download artifacts for their assigned policy. + policyID := agent.AgentPolicyID + if policyID == "" { + policyID = agent.PolicyID + } + if policyID == "" { return ErrAgentPolicyIDMissing } - p, err := at.pm.GetPolicy(ctx, agent.AgentPolicyID) + p, err := at.pm.GetPolicy(ctx, policyID) if errors.Is(err, policy.ErrPolicyNotFound) { return ErrUnauthorizedArtifact } diff --git a/internal/pkg/api/handleArtifacts_test.go b/internal/pkg/api/handleArtifacts_test.go index 7c158d915b..5cdc3965cb 100644 --- a/internal/pkg/api/handleArtifacts_test.go +++ b/internal/pkg/api/handleArtifacts_test.go @@ -214,6 +214,14 @@ func TestAuthorizeArtifact(t *testing.T) { }, wantErr: nil, // wrapped, so we check IsUnauthorized is false }, + { + name: "authorized: uses PolicyID when AgentPolicyID is empty (pre-checkin)", + agent: &model.Agent{PolicyID: policyID}, + setupMock: func(pm *mockPolicyMonitor) { + pm.On("GetPolicy", context.Background(), policyID).Return(policyWithArtifact, nil) + }, + wantErr: nil, + }, { name: "forbidden: agent has no policy ID", agent: &model.Agent{},