Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ function assertDeliveryTraffic (testCase) {
for (const request of cdnRequests) {
assert.strictEqual(request.url, `${AGENTLESS_PATH}?case=${testCase.identifier}`, testCase.label)
assert.strictEqual(request.headers['accept-encoding'], 'gzip', testCase.label)
assert.strictEqual(request.headers['dd-api-key'], 'integration-api-key', testCase.label)
assert.strictEqual(request.headers['dd-api-key'], undefined, testCase.label)
assert.strictEqual(request.headers['dd-client-library-language'], 'nodejs', testCase.label)
assert.strictEqual(request.headers['dd-client-library-version'], VERSION, testCase.label)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const RETRY_JITTER = 0.2
* @property {URL} endpoint
* @property {number} pollIntervalMs
* @property {number} requestTimeoutMs
* @property {string} apiKey
* @property {string | undefined} apiKey
*/

/**
Expand Down Expand Up @@ -138,7 +138,7 @@ class AgentlessConfigurationSource {
#request (signal) {
const headers = getClientLibraryHeaders()
headers['Accept-Encoding'] = 'gzip'
headers['DD-API-KEY'] = this.#config.apiKey
if (this.#config.apiKey) headers['DD-API-KEY'] = this.#config.apiKey
if (this.#etag) headers['If-None-Match'] = this.#etag

/**
Expand Down Expand Up @@ -228,10 +228,7 @@ class AgentlessConfigurationSource {
this.#failureWarnings.add(category)

if (statusCode === 401 || statusCode === 403) {
log.warn(
'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
statusCode
)
log.warn('Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', statusCode)
} else if (statusCode) {
log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts)
} else if (attempts > 1) {
Expand Down
13 changes: 5 additions & 8 deletions packages/dd-trace/src/openfeature/configuration_source.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict'

const { isLoopbackHost } = require('../exporters/common/url')
const log = require('../log')

const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server'
Expand All @@ -27,17 +26,19 @@ function create (config, applyConfiguration) {
return
}

const hasCustomEndpoint = Boolean(baseUrl?.trim())

try {
if (!config.DD_API_KEY) {
throw new Error('DD_API_KEY is required for Feature Flagging agentless delivery')
if (!hasCustomEndpoint && !config.DD_API_KEY) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

throw new Error('DD_API_KEY is required for the default Datadog Feature Flagging endpoint')
}

const AgentlessConfigurationSource = require('./agentless_configuration_source')
return new AgentlessConfigurationSource({
endpoint: endpoint(config, baseUrl),
pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000,
requestTimeoutMs: requestTimeoutSeconds * 1000,
apiKey: config.DD_API_KEY,
apiKey: hasCustomEndpoint ? undefined : config.DD_API_KEY,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}, applyConfiguration)
} catch (error) {
log.error('Unable to configure Feature Flagging configuration source', error)
Expand Down Expand Up @@ -74,10 +75,6 @@ function endpoint (config, configuredBaseUrl) {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Feature Flagging agentless URL must use HTTP or HTTPS')
}
if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
throw new Error('Feature Flagging agentless URL must use HTTPS unless it targets loopback')
}

if (url.pathname === '' || url.pathname === '/') {
url.pathname = DEFAULT_AGENTLESS_PATH
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,15 @@ describe('AgentlessConfigurationSource', () => {
clock.restore()
clock = undefined
const body = zlib.gzipSync(responseBody())
nock('http://127.0.0.1:8080', {
config.endpoint = new URL('http://flags.dev.internal:8080/custom/ufc')
delete config.apiKey
nock('http://flags.dev.internal:8080', {
badheaders: ['dd-api-key'],
reqheaders: {
'accept-encoding': 'gzip',
'dd-api-key': 'test-api-key',
},
})
.get('/api/v2/feature-flagging/config/rules-based/server')
.get('/custom/ufc')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

.reply(200, body, {
'content-encoding': 'gzip',
etag: '"real-path"',
Expand Down Expand Up @@ -414,7 +416,7 @@ describe('AgentlessConfigurationSource', () => {
'third',
])
assert.deepStrictEqual(log.warn.thirdCall.args, [
'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication',
401,
])
})
Expand Down Expand Up @@ -480,7 +482,7 @@ describe('AgentlessConfigurationSource', () => {
assert.strictEqual(requests.length, 1)
sinon.assert.calledOnceWithExactly(
log.warn,
'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication',
401
)

Expand All @@ -491,6 +493,22 @@ describe('AgentlessConfigurationSource', () => {
sinon.assert.notCalled(applyConfiguration)
})

it('omits a missing API key and reports the endpoint authentication failure', async () => {
delete config.apiKey
responses.push({ statusCode: 401 })

source().start()
await flush()

assert.strictEqual(Object.hasOwn(requests[0].options.headers, 'DD-API-KEY'), false)
sinon.assert.calledOnceWithExactly(
log.warn,
'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication',
401
)
sinon.assert.notCalled(applyConfiguration)
})

it('uses fixed-delay polling after a request completes', async () => {
responses.push(
{ pending: true },
Expand Down
50 changes: 32 additions & 18 deletions packages/dd-trace/test/openfeature/configuration_source.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,37 @@ describe('OpenFeature configuration source', () => {
it(`allows the loopback endpoint ${baseUrl}`, () => {
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = baseUrl

const resolved = createSourceConfig()
assert.strictEqual(
createSourceConfig().endpoint.toString(),
resolved.endpoint.toString(),
`${baseUrl}/api/v2/feature-flagging/config/rules-based/server`
)
assert.strictEqual(resolved.apiKey, undefined)
})
}

it('allows a cleartext custom endpoint for local development and proxies', () => {
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL =
'http://flags.dev.internal:8080'

const resolved = createSourceConfig()
assert.strictEqual(
resolved.endpoint.toString(),
'http://flags.dev.internal:8080/api/v2/feature-flagging/config/rules-based/server'
)
assert.strictEqual(resolved.apiKey, undefined)
})

it('preserves an exact configured path and query', () => {
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL =
'https://example.com/custom/ufc?tenant=one'

const resolved = createSourceConfig()
assert.strictEqual(
createSourceConfig().endpoint.toString(),
resolved.endpoint.toString(),
'https://example.com/custom/ufc?tenant=one'
)
assert.strictEqual(resolved.apiKey, undefined)
})

it('derives and creates the managed GovCloud endpoint without hard-coding availability', () => {
Expand Down Expand Up @@ -156,20 +172,6 @@ describe('OpenFeature configuration source', () => {
sinon.assert.notCalled(AgentlessConfigurationSource)
})

it('rejects cleartext non-loopback endpoints', () => {
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://flags.example.test'

const source = configurationSource.create(config, sinon.spy())

assert.strictEqual(source, undefined)
sinon.assert.calledOnceWithMatch(
log.error,
'Unable to configure Feature Flagging configuration source',
sinon.match.instanceOf(Error)
)
sinon.assert.notCalled(AgentlessConfigurationSource)
})

it('rejects malformed endpoints without logging their sensitive value', () => {
const sentinel = 'sensitive-value'
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = `https://${sentinel} value`
Expand All @@ -187,15 +189,27 @@ describe('OpenFeature configuration source', () => {
assert.strictEqual(log.error.firstCall.args[1].cause, undefined)
})

it('requires an API key without enabling a source', () => {
it('creates a source for a custom API without a Datadog API key', () => {
delete config.DD_API_KEY
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL =
'https://flags.example.test/custom/ufc'

const source = configurationSource.create(config, sinon.spy())

assert.ok(source instanceof AgentlessConfigurationSource)
assert.strictEqual(AgentlessConfigurationSource.firstCall.args[0].apiKey, undefined)
sinon.assert.notCalled(log.error)
})

it('requires a Datadog API key for the default Datadog Feature Flagging endpoint', () => {
delete config.DD_API_KEY

const source = configurationSource.create(config, sinon.spy())

sinon.assert.calledOnceWithMatch(
log.error,
'Unable to configure Feature Flagging configuration source',
sinon.match.instanceOf(Error)
sinon.match.has('message', 'DD_API_KEY is required for the default Datadog Feature Flagging endpoint')
)
assert.strictEqual(source, undefined)
sinon.assert.notCalled(AgentlessConfigurationSource)
Expand Down
Loading