diff --git a/.yarn/cache/@babel-helper-string-parser-npm-7.29.7-87998d618e-4d8ef0ef71.zip b/.yarn/cache/@babel-helper-string-parser-npm-7.29.7-87998d618e-4d8ef0ef71.zip new file mode 100644 index 000000000..fbdfb7922 Binary files /dev/null and b/.yarn/cache/@babel-helper-string-parser-npm-7.29.7-87998d618e-4d8ef0ef71.zip differ diff --git a/.yarn/cache/@babel-helper-validator-identifier-npm-7.29.7-9939aac13d-2efa42701e.zip b/.yarn/cache/@babel-helper-validator-identifier-npm-7.29.7-9939aac13d-2efa42701e.zip new file mode 100644 index 000000000..30ca30199 Binary files /dev/null and b/.yarn/cache/@babel-helper-validator-identifier-npm-7.29.7-9939aac13d-2efa42701e.zip differ diff --git a/.yarn/cache/@babel-parser-npm-7.29.7-90d40e76ce-da40c5928c.zip b/.yarn/cache/@babel-parser-npm-7.29.7-90d40e76ce-da40c5928c.zip new file mode 100644 index 000000000..3de675d10 Binary files /dev/null and b/.yarn/cache/@babel-parser-npm-7.29.7-90d40e76ce-da40c5928c.zip differ diff --git a/.yarn/cache/@babel-types-npm-7.29.7-8e5b8d613f-bd4f5635db.zip b/.yarn/cache/@babel-types-npm-7.29.7-8e5b8d613f-bd4f5635db.zip new file mode 100644 index 000000000..f309059b9 Binary files /dev/null and b/.yarn/cache/@babel-types-npm-7.29.7-8e5b8d613f-bd4f5635db.zip differ diff --git a/.yarn/cache/@inquirer-password-npm-2.1.7-95803bc3ba-6ae1f73fe6.zip b/.yarn/cache/@inquirer-password-npm-2.1.7-95803bc3ba-6ae1f73fe6.zip new file mode 100644 index 000000000..edad38bf1 Binary files /dev/null and b/.yarn/cache/@inquirer-password-npm-2.1.7-95803bc3ba-6ae1f73fe6.zip differ diff --git a/.yarn/cache/@jridgewell-remapping-patch-12a506c5d7-bd73d9754d.zip b/.yarn/cache/@jridgewell-remapping-patch-12a506c5d7-bd73d9754d.zip new file mode 100644 index 000000000..c100b924a Binary files /dev/null and b/.yarn/cache/@jridgewell-remapping-patch-12a506c5d7-bd73d9754d.zip differ diff --git a/.yarn/cache/magicast-npm-0.5.3-187c4bb64b-436ad51872.zip b/.yarn/cache/magicast-npm-0.5.3-187c4bb64b-436ad51872.zip new file mode 100644 index 000000000..fc318772a Binary files /dev/null and b/.yarn/cache/magicast-npm-0.5.3-187c4bb64b-436ad51872.zip differ diff --git a/LICENSES-3rdparty.csv b/LICENSES-3rdparty.csv index e4ecfa156..f394ccbd8 100644 --- a/LICENSES-3rdparty.csv +++ b/LICENSES-3rdparty.csv @@ -134,6 +134,7 @@ Component,Origin,Licence,Copyright @inquirer/core,npm,MIT,Simon Boudrias (https://github.com/SBoudrias/Inquirer.js/blob/master/packages/core/README.md) @inquirer/figures,npm,MIT,Simon Boudrias (https://www.npmjs.com/package/@inquirer/figures) @inquirer/input,npm,MIT,Simon Boudrias (https://github.com/SBoudrias/Inquirer.js/blob/master/packages/input/README.md) +@inquirer/password,npm,MIT,Simon Boudrias (https://github.com/SBoudrias/Inquirer.js/blob/master/packages/password/README.md) @inquirer/select,npm,MIT,Simon Boudrias (https://github.com/SBoudrias/Inquirer.js/blob/master/packages/select/README.md) @inquirer/type,npm,MIT,Simon Boudrias (https://www.npmjs.com/package/@inquirer/type) @isaacs/balanced-match,npm,MIT,(https://www.npmjs.com/package/@isaacs/balanced-match) @@ -603,6 +604,7 @@ log-symbols,npm,MIT,Sindre Sorhus (sindresorhus.com) log-update,npm,MIT,Sindre Sorhus (sindresorhus.com) lru-cache,npm,ISC,Isaac Z. Schlueter (https://www.npmjs.com/package/lru-cache) magic-string,npm,MIT,Rich Harris (https://www.npmjs.com/package/magic-string) +magicast,npm,MIT,(https://www.npmjs.com/package/magicast) make-dir,npm,MIT,Sindre Sorhus (sindresorhus.com) make-error,npm,ISC,Julien Fontanet (https://github.com/JsCommunity/make-error) makeerror,npm,BSD-3-Clause,Naitik Shah (https://www.npmjs.com/package/makeerror) diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 7b232261e..7bc52ff4f 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -26,6 +26,8 @@ A plugin to upload assets to Datadog's storage - [apps.permissions.protectionLevel](#appspermissionsprotectionlevel) - [apps.permissions.runAs](#appspermissionsrunas) - [apps.publish](#appspublish) + - [apps.secretConnections](#appssecretconnections) +- [Secret Store CLI](#secret-store-cli) ## Configuration @@ -47,6 +49,7 @@ apps?: { method?: 'apiKey' | 'oauth'; }; publish?: boolean; + secretConnections?: string[]; } ``` @@ -157,3 +160,28 @@ When `true` (the default), the plugin publishes the uploaded version to live imm You can also disable publishing via the `DATADOG_APPS_PUBLISH=false` (or `DD_APPS_PUBLISH=false`) environment variable. The explicit `apps.publish` config takes precedence over the environment variable. The `datadog-apps deploy --no-publish` CLI command sets this automatically — prefer the CLI over configuring this directly. + +### apps.secretConnections + +> default: `undefined` (no additional connections) + +IDs of Custom Credentials connections (secret stores) to make available to **every** backend function of this app, regardless of whether that function's code references a `connectionId`. Each connection's secrets are injected as environment variables at runtime (e.g. a secret named `STRIPE_API_KEY` is available as `process.env.STRIPE_API_KEY`). + +Managed via the `apps-secrets` CLI (see [Secret Store CLI](#secret-store-cli) below), which creates the connection and adds its ID here automatically — you shouldn't normally need to edit this by hand. + +> [!WARNING] +> Creating/updating/deleting Custom Credentials connections currently requires a backend endpoint that Datadog has not yet exposed for API-key-authenticated callers. Until that endpoint exists, the `apps-secrets` CLI commands below cannot succeed against a real org — see [Secret Store CLI](#secret-store-cli). + +## Secret Store CLI + +Manage Custom Credentials secret stores from the command line with `yarn cli apps-secrets `: + +- `apps-secrets create --name FOO --name BAR` — creates a new secret store, prompts for each secret's value (never accepted as a command argument), and adds the resulting connection ID to `apps.secretConnections` in your Vite config. +- `apps-secrets set [connectionId] --name FOO --remove BAR` — adds/updates secrets (prompting for new values) and/or removes secrets on the connection configured in your Vite config. Pass `connectionId` explicitly if you have more than one. +- `apps-secrets delete [connectionId]` — deletes the connection and removes it from your Vite config. +- `apps-secrets list` — prints the secret *names* on each configured connection. Secret values are never printed — the API doesn't return them once stored. + +Each subcommand accepts `--config ` to point at a Vite config file other than `vite.config.ts`. + +> [!WARNING] +> As noted above, these commands depend on a backend endpoint that doesn't exist yet for API-key-authenticated callers — they will not work against a real Datadog org until that dependency is resolved. diff --git a/packages/plugins/apps/src/connections.test.ts b/packages/plugins/apps/src/connections.test.ts new file mode 100644 index 000000000..e2cc13f52 --- /dev/null +++ b/packages/plugins/apps/src/connections.test.ts @@ -0,0 +1,181 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { getConnectionsClient } from '@dd/apps-plugin/connections'; +import { doRequest } from '@dd/core/helpers/request'; + +jest.mock('@dd/core/helpers/request', () => { + const actual = jest.requireActual('@dd/core/helpers/request'); + return { + ...actual, + doRequest: jest.fn(), + }; +}); + +const doRequestMock = jest.mocked(doRequest); +const auth = { apiKey: 'api-key', appKey: 'app-key' }; + +const readBody = async (stream: AsyncIterable) => { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + return JSON.parse(Buffer.concat(chunks).toString('utf-8')); +}; + +// This client targets an endpoint that does not exist yet for API-key-authenticated +// callers (see connections.ts's module doc) — these tests only pin down the request/ +// response shape this module is written against, not that it works against a real API. +describe('Apps Plugin - connections', () => { + beforeEach(() => { + doRequestMock.mockReset(); + }); + + describe('createSecretStore', () => { + test('Should POST a custom_connections payload and return the new id', async () => { + doRequestMock.mockResolvedValue({ + data: { id: 'conn-123', attributes: { name: 'x' } }, + }); + + const client = getConnectionsClient(auth, 'datadoghq.com'); + const id = await client.createSecretStore('My secrets', [ + { name: 'STRIPE_API_KEY', value: 'sk_live_123' }, + ]); + + expect(id).toBe('conn-123'); + expect(doRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://api.datadoghq.com/api/unstable/actions/connections', + method: 'POST', + type: 'json', + auth, + getData: expect.any(Function), + }), + ); + + const { getData } = doRequestMock.mock.calls[0][0]; + const { data } = await getData!(); + const body = await readBody(data as any); + expect(body).toEqual({ + data: { + type: 'custom_connections', + attributes: { + name: 'My secrets', + kind: 'TOKEN_AUTH', + integration: 'INTEGRATION_CUSTOM_CREDENTIALS', + data: { + tokenAuth: { + tokens: [ + { + name: 'STRIPE_API_KEY', + kind: 'PLAINTEXT', + plaintextValue: { value: 'sk_live_123' }, + }, + ], + }, + }, + }, + }, + }); + }); + }); + + describe('getSecretStore', () => { + test('Should GET the connection and map tokens, never exposing plaintext values', async () => { + doRequestMock.mockResolvedValue({ + data: { + id: 'conn-123', + attributes: { + name: 'My secrets', + data: { + tokenAuth: { + tokens: [ + { + name: 'STRIPE_API_KEY', + kind: 'SECRET', + secretValue: { value: 'ref-1' }, + }, + ], + }, + }, + }, + }, + }); + + const client = getConnectionsClient(auth, 'datadoghq.com'); + const store = await client.getSecretStore('conn-123'); + + expect(doRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://api.datadoghq.com/api/unstable/actions/connections/conn-123', + type: 'json', + auth, + }), + ); + expect(store).toEqual({ + name: 'My secrets', + tokens: [{ name: 'STRIPE_API_KEY', kind: 'SECRET', ref: 'ref-1' }], + }); + }); + + test('Should return an empty tokens array when the connection has none', async () => { + doRequestMock.mockResolvedValue({ + data: { id: 'conn-123', attributes: { name: 'My secrets' } }, + }); + + const client = getConnectionsClient(auth, 'datadoghq.com'); + const store = await client.getSecretStore('conn-123'); + + expect(store.tokens).toEqual([]); + }); + }); + + describe('updateSecretStore', () => { + test('Should PATCH with the full merged token array', async () => { + doRequestMock.mockResolvedValue({ + data: { id: 'conn-123', attributes: { name: 'x' } }, + }); + + const client = getConnectionsClient(auth, 'datadoghq.com'); + await client.updateSecretStore('conn-123', [ + { name: 'UNCHANGED', kind: 'SECRET', ref: 'ref-1' }, + { name: 'ROTATED', value: 'new-value' }, + ]); + + expect(doRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://api.datadoghq.com/api/unstable/actions/connections/conn-123', + method: 'PATCH', + type: 'json', + auth, + }), + ); + + const { getData } = doRequestMock.mock.calls[0][0]; + const { data } = await getData!(); + const body = await readBody(data as any); + expect(body.data.attributes.data.tokenAuth.tokens).toEqual([ + { name: 'UNCHANGED', kind: 'SECRET', secretValue: { value: 'ref-1' } }, + { name: 'ROTATED', kind: 'PLAINTEXT', plaintextValue: { value: 'new-value' } }, + ]); + }); + }); + + describe('deleteSecretStore', () => { + test('Should DELETE the connection', async () => { + doRequestMock.mockResolvedValue(undefined); + + const client = getConnectionsClient(auth, 'datadoghq.com'); + await client.deleteSecretStore('conn-123'); + + expect(doRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://api.datadoghq.com/api/unstable/actions/connections/conn-123', + method: 'DELETE', + auth, + }), + ); + }); + }); +}); diff --git a/packages/plugins/apps/src/connections.ts b/packages/plugins/apps/src/connections.ts new file mode 100644 index 000000000..20a75f1ad --- /dev/null +++ b/packages/plugins/apps/src/connections.ts @@ -0,0 +1,170 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { doRequest } from '@dd/core/helpers/request'; +import type { ApiKeyRequestAuthOptions, Site } from '@dd/core/types'; +import { Readable } from 'stream'; + +// NOTE: this targets a Custom Credentials connections endpoint that does not exist yet. +// The only place Custom Credentials connections can be created/updated/deleted today is a +// session-cookie-authenticated internal endpoint (`/api/v2/connection/custom_connections`, +// used by web-ui's connection form), which an API-key-authenticated CLI cannot call. This +// module is written against the shape that endpoint is expected to mirror once the +// actionplatform/connections team exposes an API-key-authenticated equivalent — treat the +// path and payload shape below as provisional until confirmed with that team. +export const CUSTOM_CONNECTIONS_API_PATH = 'api/unstable/actions/connections'; + +// A secret as read back from the API. Once stored, a secret is always `kind: 'SECRET'` — +// its plaintext value never round-trips back out; `ref` is an opaque handle that can be +// resent on update to mean "keep this secret's current value unchanged". A freshly-created +// EXTERNAL_SECRETS_MANAGER-backed token has no ref (there's nothing to un-encrypt). +export type SecretToken = + | { name: string; kind: 'SECRET'; ref: string } + | { name: string; kind: 'EXTERNAL_SECRETS_MANAGER' }; + +// A token as sent to the API when creating/updating a connection: either a brand-new/ +// changed plaintext value, or an existing token carried forward unchanged by its `ref`. +export type SecretTokenInput = { name: string; value: string } | SecretToken; + +type PostableToken = + | { name: string; kind: 'PLAINTEXT'; plaintextValue: { value: string } } + | { name: string; kind: 'SECRET'; secretValue: { value: string } } + | { name: string; kind: 'EXTERNAL_SECRETS_MANAGER' }; + +type CustomConnectionAttributes = { + name: string; + kind: 'TOKEN_AUTH'; + integration: 'INTEGRATION_CUSTOM_CREDENTIALS'; + data: { + tokenAuth: { + tokens: PostableToken[]; + }; + }; +}; + +// The wire shape of a stored token as returned by GET — `secretValue`/`externalSecretsManager` +// carry an opaque reference rather than the real value, mirroring how AWS credentials are +// returned (masked) elsewhere in the public Actions Connections API. +type RawToken = { + name: string; + kind: 'SECRET' | 'EXTERNAL_SECRETS_MANAGER' | 'PLAINTEXT'; + secretValue?: { value: string }; +}; + +type CustomConnectionResponse = { + data: { + id: string; + attributes: { + name: string; + data?: { + tokenAuth?: { + tokens?: RawToken[]; + }; + }; + }; + }; +}; + +const toPostableToken = (token: SecretTokenInput): PostableToken => { + if ('value' in token) { + return { name: token.name, kind: 'PLAINTEXT', plaintextValue: { value: token.value } }; + } + if (token.kind === 'SECRET') { + return { name: token.name, kind: 'SECRET', secretValue: { value: token.ref } }; + } + return { name: token.name, kind: 'EXTERNAL_SECRETS_MANAGER' }; +}; + +const fromRawToken = (token: RawToken): SecretToken => { + if (token.kind === 'EXTERNAL_SECRETS_MANAGER') { + return { name: token.name, kind: 'EXTERNAL_SECRETS_MANAGER' }; + } + // A PLAINTEXT-kind token has no ref to carry forward. This shouldn't happen for a + // stored token (the backend is expected to always convert to SECRET on save), but if + // it does, surface it as a SECRET with an empty ref rather than silently dropping data + // — callers (see apps-secrets `set`) must then either rotate or remove that name. + return { name: token.name, kind: 'SECRET', ref: token.secretValue?.value ?? '' }; +}; + +const buildAttributes = (name: string, tokens: SecretTokenInput[]): CustomConnectionAttributes => ({ + name, + kind: 'TOKEN_AUTH', + integration: 'INTEGRATION_CUSTOM_CREDENTIALS', + data: { tokenAuth: { tokens: tokens.map(toPostableToken) } }, +}); + +const jsonData = (body: unknown) => async () => ({ + data: Readable.from(JSON.stringify(body)), + headers: { 'Content-Type': 'application/json' }, +}); + +export type ConnectionsClient = { + createSecretStore: (name: string, tokens: SecretTokenInput[]) => Promise; + getSecretStore: (connectionId: string) => Promise<{ name: string; tokens: SecretToken[] }>; + updateSecretStore: (connectionId: string, tokens: SecretTokenInput[]) => Promise; + deleteSecretStore: (connectionId: string) => Promise; +}; + +// Behind an interface so the real endpoint/payload shape can be swapped in behind +// getConnectionsClient() once the backend dependency (see module doc above) is resolved, +// without touching any call site. +export const getConnectionsClient = ( + auth: Required, + site: Site, +): ConnectionsClient => { + const baseUrl = `https://api.${site}/${CUSTOM_CONNECTIONS_API_PATH}`; + + return { + createSecretStore: async (name, tokens) => { + const response = await doRequest({ + url: baseUrl, + method: 'POST', + type: 'json', + auth, + getData: jsonData({ + data: { type: 'custom_connections', attributes: buildAttributes(name, tokens) }, + }), + }); + return response.data.id; + }, + + getSecretStore: async (connectionId) => { + const response = await doRequest({ + url: `${baseUrl}/${connectionId}`, + type: 'json', + auth, + }); + return { + name: response.data.attributes.name, + tokens: (response.data.attributes.data?.tokenAuth?.tokens ?? []).map(fromRawToken), + }; + }, + + updateSecretStore: async (connectionId, tokens) => { + await doRequest({ + url: `${baseUrl}/${connectionId}`, + method: 'PATCH', + type: 'json', + auth, + getData: jsonData({ + data: { + type: 'custom_connections', + id: connectionId, + attributes: { + data: { tokenAuth: { tokens: tokens.map(toPostableToken) } }, + }, + }, + }), + }); + }, + + deleteSecretStore: async (connectionId) => { + await doRequest({ + url: `${baseUrl}/${connectionId}`, + method: 'DELETE', + auth, + }); + }, + }; +}; diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts index e7f3b18de..40e649b05 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -38,6 +38,12 @@ export type AppsOptions = { authOverrides?: { method?: AuthMethod; }; + /** + * IDs of Custom Credentials connections (secret stores managed via `yarn cli apps-secrets`) + * to make available to every backend function, regardless of whether its code references + * a connectionId. Merged into each function's allowedConnectionIds at build time. + */ + secretConnections?: string[]; }; export type AppsManifest = { diff --git a/packages/plugins/apps/src/validate.test.ts b/packages/plugins/apps/src/validate.test.ts index 895ab4628..d2c62b37f 100644 --- a/packages/plugins/apps/src/validate.test.ts +++ b/packages/plugins/apps/src/validate.test.ts @@ -223,4 +223,23 @@ describe('new app properties', () => { expect(result).not.toHaveProperty('selfService'); expect(result).not.toHaveProperty('permissions'); }); + + test('Should pass secretConnections through to resolved options', () => { + const result = validateOptions({ + apps: { secretConnections: ['conn-1', 'conn-2'] }, + }); + expect(result.secretConnections).toEqual(['conn-1', 'conn-2']); + }); + + test('Should dedupe secretConnections', () => { + const result = validateOptions({ + apps: { secretConnections: ['conn-1', 'conn-2', 'conn-1'] }, + }); + expect(result.secretConnections).toEqual(['conn-1', 'conn-2']); + }); + + test('Should omit secretConnections entirely when not configured', () => { + const result = validateOptions({ apps: {} }); + expect(result).not.toHaveProperty('secretConnections'); + }); }); diff --git a/packages/plugins/apps/src/validate.ts b/packages/plugins/apps/src/validate.ts index 00b8f38c3..18ea9440d 100644 --- a/packages/plugins/apps/src/validate.ts +++ b/packages/plugins/apps/src/validate.ts @@ -47,6 +47,9 @@ export const validateOptions = (options: Options): AppsOptionsWithDefaults => { ...(resolvedOptions.description != null && { description: resolvedOptions.description }), ...(resolvedOptions.selfService != null && { selfService: resolvedOptions.selfService }), ...(resolvedOptions.permissions != null && { permissions: resolvedOptions.permissions }), + ...(resolvedOptions.secretConnections != null && { + secretConnections: [...new Set(resolvedOptions.secretConnections)], + }), authOverrides: { method, }, diff --git a/packages/plugins/apps/src/vite/handle-upload.test.ts b/packages/plugins/apps/src/vite/handle-upload.test.ts new file mode 100644 index 000000000..493d7dac8 --- /dev/null +++ b/packages/plugins/apps/src/vite/handle-upload.test.ts @@ -0,0 +1,80 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { BackendFunction } from '@dd/apps-plugin/backend/types'; +import type { AppsOptionsWithDefaults } from '@dd/apps-plugin/types'; +import { buildManifest } from '@dd/apps-plugin/vite/handle-upload'; + +const baseOptions: AppsOptionsWithDefaults = { + include: [], + dryRun: true, + authOverrides: { method: 'apiKey' }, +}; + +const makeFunction = (overrides: Partial = {}): BackendFunction => ({ + relativePath: 'src/my-function.backend', + name: 'myFunction', + absolutePath: '/root/src/my-function.backend.ts', + allowedConnectionIds: [], + ...overrides, +}); + +describe('Apps Plugin - buildManifest', () => { + test('Should merge secretConnections into every function, even those with no code-derived connections', () => { + const manifest = buildManifest([makeFunction()], { + ...baseOptions, + secretConnections: ['secret-conn-1'], + }); + + const [functionEntry] = Object.values(manifest.backend.functions); + expect(functionEntry.allowedConnectionIds).toEqual(['secret-conn-1']); + }); + + test('Should merge secretConnections alongside a function-code-derived connection, deduped', () => { + const manifest = buildManifest( + [makeFunction({ allowedConnectionIds: ['code-conn', 'secret-conn-1'] })], + { ...baseOptions, secretConnections: ['secret-conn-1', 'secret-conn-2'] }, + ); + + const [functionEntry] = Object.values(manifest.backend.functions); + expect(functionEntry.allowedConnectionIds).toEqual([ + 'code-conn', + 'secret-conn-1', + 'secret-conn-2', + ]); + }); + + test('Should apply secretConnections to every function independently', () => { + const manifest = buildManifest( + [ + makeFunction({ relativePath: 'src/a.backend', name: 'a' }), + makeFunction({ + relativePath: 'src/b.backend', + name: 'b', + allowedConnectionIds: ['b-only'], + }), + ], + { ...baseOptions, secretConnections: ['shared-conn'] }, + ); + + const entries = Object.values(manifest.backend.functions); + expect(entries).toHaveLength(2); + for (const entry of entries) { + expect(entry.allowedConnectionIds).toContain('shared-conn'); + } + expect( + entries.find((e) => e.allowedConnectionIds.includes('b-only'))?.allowedConnectionIds, + ).toEqual(['b-only', 'shared-conn']); + }); + + test('Should leave allowedConnectionIds untouched when secretConnections is not configured', () => { + const manifest = buildManifest( + [makeFunction({ allowedConnectionIds: ['code-conn'] })], + baseOptions, + ); + + const [functionEntry] = Object.values(manifest.backend.functions); + expect(functionEntry.allowedConnectionIds).toEqual(['code-conn']); + }); +}); diff --git a/packages/plugins/apps/src/vite/handle-upload.ts b/packages/plugins/apps/src/vite/handle-upload.ts index 8d5c75290..fae3fbdc1 100644 --- a/packages/plugins/apps/src/vite/handle-upload.ts +++ b/packages/plugins/apps/src/vite/handle-upload.ts @@ -32,14 +32,20 @@ export interface HandleUploadOptions { options: AppsOptionsWithDefaults; } -function buildManifest( +export function buildManifest( backendFunctions: BackendFunction[], options: AppsOptionsWithDefaults, ): AppsManifest { const functions: AppsManifest['backend']['functions'] = {}; for (const func of backendFunctions) { functions[encodeQueryName(func)] = { - allowedConnectionIds: [...func.allowedConnectionIds], + // secretConnections are app-level connections (e.g. a Custom Credentials + // secret store) made available to every backend function regardless of + // whether its code references a connectionId — merged in here rather than in + // the AST-based collector, which stays focused on statically-provable IDs. + allowedConnectionIds: [ + ...new Set([...func.allowedConnectionIds, ...(options.secretConnections ?? [])]), + ], }; } diff --git a/packages/tools/package.json b/packages/tools/package.json index e2724f687..91bcb0614 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -37,6 +37,7 @@ "@datadog/rspack-plugin": "workspace:*", "@datadog/vite-plugin": "workspace:*", "@datadog/webpack-plugin": "workspace:*", + "@dd/apps-plugin": "workspace:*", "@dd/assets": "workspace:*", "@dd/core": "workspace:*", "@dd/error-tracking-plugin": "workspace:*", @@ -45,6 +46,7 @@ "@dd/rum-plugin": "workspace:*", "@inquirer/checkbox": "2.3.3", "@inquirer/input": "2.1.7", + "@inquirer/password": "2.1.7", "@inquirer/select": "2.3.3", "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-node-resolve": "15.3.0", @@ -53,6 +55,7 @@ "clipanion": "4.0.0-rc.3", "esbuild": "0.25.8", "glob": "11.1.0", + "magicast": "0.5.3", "outdent": "0.8.0", "rollup-plugin-import-css": "4.0.2", "typanion": "3.14.0", diff --git a/packages/tools/src/commands/apps-secrets/ask.ts b/packages/tools/src/commands/apps-secrets/ask.ts new file mode 100644 index 000000000..8e89c38e9 --- /dev/null +++ b/packages/tools/src/commands/apps-secrets/ask.ts @@ -0,0 +1,19 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import password from '@inquirer/password'; + +// Secret values are never accepted as CLI arguments (visible in shell history and via +// ps/proc to other users on the host) — they're always collected one at a time through a +// masked prompt. Sequential, not Promise.all, so prompts don't interleave on the terminal. +export const promptSecretValues = async ( + names: string[], +): Promise<{ name: string; value: string }[]> => { + const tokens: { name: string; value: string }[] = []; + for (const name of names) { + const value = await password({ message: `Enter value for ${name}:`, mask: true }); + tokens.push({ name, value }); + } + return tokens; +}; diff --git a/packages/tools/src/commands/apps-secrets/auth.ts b/packages/tools/src/commands/apps-secrets/auth.ts new file mode 100644 index 000000000..fb7933f0b --- /dev/null +++ b/packages/tools/src/commands/apps-secrets/auth.ts @@ -0,0 +1,35 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { DEFAULT_SITE, SITES } from '@dd/core/constants'; +import { getDDEnvValue } from '@dd/core/helpers/env'; +import type { ApiKeyRequestAuthOptions, Site } from '@dd/core/types'; + +export const AUTH_GUIDANCE = 'Set DD_API_KEY and DD_APP_KEY (or DATADOG_API_KEY/DATADOG_APP_KEY).'; + +export class MissingAuthenticationError extends Error { + constructor() { + super(`Missing authentication. ${AUTH_GUIDANCE}`); + this.name = 'MissingAuthenticationError'; + } +} + +const isSite = (value: string): value is Site => (SITES as readonly string[]).includes(value); + +// The apps-secrets CLI runs standalone, outside any bundler plugin context, so unlike +// packages/plugins/apps/src/auth.ts it only resolves API/App key auth from the +// environment — there is no OAuth flow or plugin options object to read from here. +export const resolveAuth = (): { auth: Required; site: Site } => { + const apiKey = getDDEnvValue('API_KEY'); + const appKey = getDDEnvValue('APP_KEY'); + + if (!apiKey || !appKey) { + throw new MissingAuthenticationError(); + } + + const siteValue = getDDEnvValue('SITE'); + const site = siteValue && isSite(siteValue) ? siteValue : DEFAULT_SITE; + + return { auth: { apiKey, appKey }, site }; +}; diff --git a/packages/tools/src/commands/apps-secrets/config.ts b/packages/tools/src/commands/apps-secrets/config.ts new file mode 100644 index 000000000..d81032feb --- /dev/null +++ b/packages/tools/src/commands/apps-secrets/config.ts @@ -0,0 +1,109 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import fs from 'fs'; + +export const DEFAULT_VITE_CONFIG_PATH = 'vite.config.ts'; +const DATADOG_VITE_PLUGIN_PACKAGE = '@datadog/vite-plugin'; + +export class ConfigLocationError extends Error {} + +// Narrowly scoped to the two common vite.config.ts shapes: +// export default defineConfig({ plugins: [datadogVitePlugin({ ... })] }) +// export default { plugins: [datadogVitePlugin({ ... })] } +// Anything else (a function export, an async config, a config split across files) isn't +// safely editable without risking corrupting the user's file, so we fail loudly and let +// them add the option by hand instead. +const findDatadogPluginOptions = (mod: any): Record => { + const importEntry = Object.values(mod.imports).find( + (entry) => entry.from === DATADOG_VITE_PLUGIN_PACKAGE, + ); + if (!importEntry) { + throw new ConfigLocationError( + `Could not find an import from "${DATADOG_VITE_PLUGIN_PACKAGE}" in the config file.`, + ); + } + + const exportedDefault = mod.exports.default; + const config = + exportedDefault?.$type === 'function-call' ? exportedDefault.$args[0] : exportedDefault; + const plugins = config?.plugins; + if (!Array.isArray(plugins)) { + throw new ConfigLocationError('Could not find a "plugins" array in the config file.'); + } + + const pluginCall = plugins.find((plugin: any) => plugin?.$callee === importEntry.local); + if (!pluginCall) { + throw new ConfigLocationError( + `Could not find a call to "${importEntry.local}(...)" in the "plugins" array.`, + ); + } + + if (!pluginCall.$args[0]) { + pluginCall.$args[0] = {}; + } + if (!pluginCall.$args[0].apps) { + pluginCall.$args[0].apps = {}; + } + return pluginCall.$args[0].apps; +}; + +export const readSecretConnections = async (configPath: string): Promise => { + if (!fs.existsSync(configPath)) { + return []; + } + const { loadFile } = await import('magicast'); + const mod = await loadFile(configPath); + const apps = findDatadogPluginOptions(mod); + const secretConnections = apps.secretConnections; + return Array.isArray(secretConnections) ? [...secretConnections] : []; +}; + +// Writes the full set of secret-store connection IDs back to the config, preserving +// formatting/comments elsewhere in the file. An empty array removes the field entirely, +// rather than leaving `secretConnections: []` behind. +export const writeSecretConnections = async ( + configPath: string, + secretConnections: string[], +): Promise => { + if (!fs.existsSync(configPath)) { + throw new ConfigLocationError(`Config file not found: ${configPath}`); + } + const { loadFile, writeFile } = await import('magicast'); + const mod = await loadFile(configPath); + const apps = findDatadogPluginOptions(mod); + + if (secretConnections.length) { + apps.secretConnections = secretConnections; + } else { + delete apps.secretConnections; + } + + await writeFile(mod, configPath); +}; + +// Resolves which connection id a set/delete operation should target: the explicit id if +// given, or the sole id found in the config. Refuses to guess when there are several — +// picking the wrong one would mutate/delete the wrong secret store. +export const resolveConnectionId = async ( + explicit: string | undefined, + configPath: string, +): Promise => { + if (explicit) { + return explicit; + } + + const stored = await readSecretConnections(configPath); + if (stored.length === 1) { + return stored[0]; + } + if (stored.length === 0) { + throw new ConfigLocationError( + `No connection id provided, and none found in ${configPath}.`, + ); + } + throw new ConfigLocationError( + `Multiple secret store connections found in ${configPath} — pass one explicitly: ${stored.join(', ')}`, + ); +}; diff --git a/packages/tools/src/commands/apps-secrets/index.ts b/packages/tools/src/commands/apps-secrets/index.ts new file mode 100644 index 000000000..a1b076af4 --- /dev/null +++ b/packages/tools/src/commands/apps-secrets/index.ts @@ -0,0 +1,243 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { Command, Option } from 'clipanion'; + +import { DEFAULT_VITE_CONFIG_PATH } from './config'; + +const CATEGORY = 'Datadog Apps'; + +class AppsSecretsCreate extends Command { + static paths = [['apps-secrets', 'create']]; + + static usage = Command.Usage({ + category: CATEGORY, + description: `Create a Custom Credentials secret store connection for Datadog Apps.`, + details: ` + Creates a new secret store (a "Custom Credentials" connection) and makes it + available to every backend function of the app, without needing to reference + its connection id from code. + + Secret values are never accepted as command arguments — pass the secret + names with --name and you'll be prompted for each value interactively. + + The new connection id is added to "apps.secretConnections" in your Vite + config file automatically. + `, + examples: [ + [ + `Create a secret store with two secrets`, + `$0 apps-secrets create --name STRIPE_API_KEY --name OTHER_KEY`, + ], + ], + }); + + names: string[] = Option.Array('--name', [], { + description: 'Name of a secret to add (you will be prompted for its value).', + }); + connectionName?: string = Option.String('--connection-name', { + description: 'Human-readable name for the connection. Defaults to the package name.', + }); + config: string = Option.String('--config', DEFAULT_VITE_CONFIG_PATH, { + description: 'Path to the Vite config file to update with the new connection id.', + }); + + async execute() { + const { getConnectionsClient } = await import('@dd/apps-plugin/connections'); + const { readFileSync } = await import('@dd/core/helpers/fs'); + const { resolveAuth } = await import('./auth'); + const { promptSecretValues } = await import('./ask'); + const { readSecretConnections, writeSecretConnections } = await import('./config'); + const { green, red } = await import('../../helpers'); + + if (!this.names.length) { + console.log(red('Pass at least one --name to create a secret.')); + return 1; + } + + const connectionName = + this.connectionName || + (() => { + try { + const pkg = JSON.parse(readFileSync('package.json')); + return pkg.name ? `${pkg.name} secrets` : 'Custom Credentials secrets'; + } catch { + return 'Custom Credentials secrets'; + } + })(); + + const { auth, site } = resolveAuth(); + const client = getConnectionsClient(auth, site); + + const tokens = await promptSecretValues(this.names); + const connectionId = await client.createSecretStore(connectionName, tokens); + + const existing = await readSecretConnections(this.config); + await writeSecretConnections(this.config, [...new Set([...existing, connectionId])]); + + console.log( + `${green('Created secret store')} ${green(connectionId)} and added it to ${green(this.config)}.`, + ); + } +} + +class AppsSecretsSet extends Command { + static paths = [['apps-secrets', 'set']]; + + static usage = Command.Usage({ + category: CATEGORY, + description: `Add, update, or remove secrets on an existing secret store connection.`, + details: ` + Reads the current secret names on the connection, drops any name passed to + --remove, prompts for a new value for each name passed to --name, then writes + the full merged set back. Secrets not mentioned are left untouched. + + The connection id can be omitted when your Vite config has exactly one entry + in "apps.secretConnections". + `, + examples: [ + [`Rotate one secret`, `$0 apps-secrets set --name STRIPE_API_KEY`], + [`Remove a secret`, `$0 apps-secrets set --remove OLD_KEY`], + ], + }); + + connectionId?: string = Option.String({ required: false }); + names: string[] = Option.Array('--name', [], { + description: 'Name of a secret to add or update (you will be prompted for its value).', + }); + removeNames: string[] = Option.Array('--remove', [], { + description: 'Name of a secret to remove from the connection.', + }); + config: string = Option.String('--config', DEFAULT_VITE_CONFIG_PATH, { + description: 'Path to the Vite config file to read the connection id from.', + }); + + async execute() { + const { getConnectionsClient } = await import('@dd/apps-plugin/connections'); + const { resolveAuth } = await import('./auth'); + const { promptSecretValues } = await import('./ask'); + const { resolveConnectionId } = await import('./config'); + const { green } = await import('../../helpers'); + + const connectionId = await resolveConnectionId(this.connectionId, this.config); + const { auth, site } = resolveAuth(); + const client = getConnectionsClient(auth, site); + + const current = await client.getSecretStore(connectionId); + const removeSet = new Set(this.removeNames); + const changedSet = new Set(this.names); + const unchanged = current.tokens.filter( + (token) => !removeSet.has(token.name) && !changedSet.has(token.name), + ); + + // A SECRET-kind token with no ref means the API returned it without a way to + // carry its value forward unchanged (see connections.ts's fromRawToken) — we + // can't silently resend it, so make the caller decide explicitly. + const unresolvable = unchanged.filter((token) => token.kind === 'SECRET' && !token.ref); + if (unresolvable.length) { + throw new Error( + `Cannot leave these secrets unchanged (no value reference returned by the API): ` + + `${unresolvable.map((t) => t.name).join(', ')}. Pass them to --name to set a new value, or --remove to delete them.`, + ); + } + + const changed = await promptSecretValues(this.names); + + await client.updateSecretStore(connectionId, [...unchanged, ...changed]); + + console.log(`${green('Updated secret store')} ${green(connectionId)}.`); + } +} + +class AppsSecretsDelete extends Command { + static paths = [['apps-secrets', 'delete']]; + + static usage = Command.Usage({ + category: CATEGORY, + description: `Delete a secret store connection.`, + details: ` + Deletes the connection from Datadog and removes it from + "apps.secretConnections" in your Vite config file. + + The connection id can be omitted when your Vite config has exactly one entry + in "apps.secretConnections". + `, + examples: [[`Delete the configured secret store`, `$0 apps-secrets delete`]], + }); + + connectionId?: string = Option.String({ required: false }); + config: string = Option.String('--config', DEFAULT_VITE_CONFIG_PATH, { + description: 'Path to the Vite config file to remove the connection id from.', + }); + + async execute() { + const { getConnectionsClient } = await import('@dd/apps-plugin/connections'); + const { resolveAuth } = await import('./auth'); + const { readSecretConnections, writeSecretConnections, resolveConnectionId } = await import( + './config' + ); + const { green } = await import('../../helpers'); + + const connectionId = await resolveConnectionId(this.connectionId, this.config); + const { auth, site } = resolveAuth(); + + await getConnectionsClient(auth, site).deleteSecretStore(connectionId); + + const existing = await readSecretConnections(this.config); + await writeSecretConnections( + this.config, + existing.filter((id) => id !== connectionId), + ); + + console.log(`${green('Deleted secret store')} ${green(connectionId)}.`); + } +} + +class AppsSecretsList extends Command { + static paths = [['apps-secrets', 'list']]; + + static usage = Command.Usage({ + category: CATEGORY, + description: `List secret store connections and their secret names.`, + details: ` + Never prints secret values — only names, which is all the API returns once a + secret has been stored. + `, + examples: [[`List all configured secret stores`, `$0 apps-secrets list`]], + }); + + connectionId?: string = Option.String({ required: false }); + config: string = Option.String('--config', DEFAULT_VITE_CONFIG_PATH, { + description: 'Path to the Vite config file to read connection ids from.', + }); + + async execute() { + const { getConnectionsClient } = await import('@dd/apps-plugin/connections'); + const { resolveAuth } = await import('./auth'); + const { readSecretConnections } = await import('./config'); + const { green, dim } = await import('../../helpers'); + + const ids = this.connectionId + ? [this.connectionId] + : await readSecretConnections(this.config); + + if (!ids.length) { + console.log(dim(`No secret store connections found in ${this.config}.`)); + return; + } + + const { auth, site } = resolveAuth(); + const client = getConnectionsClient(auth, site); + + for (const id of ids) { + const store = await client.getSecretStore(id); + console.log(`${green(store.name)} ${dim(`(${id})`)}`); + for (const token of store.tokens) { + console.log(` - ${token.name}`); + } + } + } +} + +export default [AppsSecretsCreate, AppsSecretsSet, AppsSecretsDelete, AppsSecretsList]; diff --git a/yarn.lock b/yarn.lock index 0ff5c99e5..67dfe7fef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -391,6 +391,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10/4d8ef0ef7105f3d9fe4361137c8f42e5b4c7a52b5380b962762f2a528a1ba89064e2c6236090716ce34b63707b886ae0ebf36b2c2fcc2851f27e652febfc3648 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.22.20, @babel/helper-validator-identifier@npm:^7.24.5": version: 7.24.5 resolution: "@babel/helper-validator-identifier@npm:7.24.5" @@ -405,6 +412,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10/2efa42701eb05babf26dff3332109c9e5e1a3400a71fb9e68ee27af28235036a2a72c2494c04bdab3f909075f42a58b2e8271074372bc7f8e79ec02bd364d7a7 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.23.5": version: 7.23.5 resolution: "@babel/helper-validator-option@npm:7.23.5" @@ -483,6 +497,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.29.3": + version: 7.29.7 + resolution: "@babel/parser@npm:7.29.7" + dependencies: + "@babel/types": "npm:^7.29.7" + bin: + parser: ./bin/babel-parser.js + checksum: 10/da40c5928c95997b01aabe84fc3440881b8f20b866714fefa142961d37e82ffc03fbb9afed706f15f8a688278f95286ca0cea0d87ad6c77600f8c6c45d9824ee + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.24.5": version: 7.24.5 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.24.5" @@ -1609,6 +1634,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.29.0, @babel/types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10/bd4f5635db1057bd0abeebf93eb3ae38399e152271cea8dce8288350f0afa13ed3e2db2e16e22bd3303068890eec18965a83420539afbe0dde31432b4cf9636d + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -2254,6 +2289,7 @@ __metadata: "@datadog/rspack-plugin": "workspace:*" "@datadog/vite-plugin": "workspace:*" "@datadog/webpack-plugin": "workspace:*" + "@dd/apps-plugin": "workspace:*" "@dd/assets": "workspace:*" "@dd/core": "workspace:*" "@dd/error-tracking-plugin": "workspace:*" @@ -2263,6 +2299,7 @@ __metadata: "@dd/tests": "workspace:*" "@inquirer/checkbox": "npm:2.3.3" "@inquirer/input": "npm:2.1.7" + "@inquirer/password": "npm:2.1.7" "@inquirer/select": "npm:2.3.3" "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-esm-shim": "npm:0.1.8" @@ -2276,6 +2313,7 @@ __metadata: esbuild: "npm:0.25.8" glob: "npm:11.1.0" lodash.template: "npm:4.5.0" + magicast: "npm:0.5.3" outdent: "npm:0.8.0" rollup-plugin-import-css: "npm:4.0.2" ts-node: "npm:10.9.2" @@ -2817,6 +2855,17 @@ __metadata: languageName: node linkType: hard +"@inquirer/password@npm:2.1.7": + version: 2.1.7 + resolution: "@inquirer/password@npm:2.1.7" + dependencies: + "@inquirer/core": "npm:^8.2.0" + "@inquirer/type": "npm:^1.3.1" + ansi-escapes: "npm:^4.3.2" + checksum: 10/6ae1f73fe66fbc1285ab86230b6f51f9b7fbaa4c48c68ce6f100f9728ee22dfe70838ef1df8fdbb3d2f02f4b219932ccba649b703f5ecccbf76d778cbce9da91 + languageName: node + linkType: hard + "@inquirer/select@npm:2.3.3": version: 2.3.3 resolution: "@inquirer/select@npm:2.3.3" @@ -8988,6 +9037,17 @@ __metadata: languageName: node linkType: hard +"magicast@npm:0.5.3": + version: 0.5.3 + resolution: "magicast@npm:0.5.3" + dependencies: + "@babel/parser": "npm:^7.29.3" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10/436ad518726b691cf9ac1a14ab14705784f28075892a092b06e8b17ac7303fe57e8a2789989c68b560653a909a8df49d1582bb73f9bdad4bcbab892201251049 + languageName: node + linkType: hard + "make-dir@npm:^3.0.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0"