Skip to content
Draft
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions LICENSES-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<!-- #toc -->

## Configuration
Expand All @@ -47,6 +49,7 @@ apps?: {
method?: 'apiKey' | 'oauth';
};
publish?: boolean;
secretConnections?: string[];
}
```

Expand Down Expand Up @@ -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 <subcommand>`:

- `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 <path>` 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.
181 changes: 181 additions & 0 deletions packages/plugins/apps/src/connections.test.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer | string>) => {
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,
}),
);
});
});
});
Loading
Loading