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
5 changes: 5 additions & 0 deletions .bumpy/aws-secrets-nested-json-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/aws-secrets-plugin": minor
---

`awsSecret()` and `awsParam()` can read nested JSON values with a dot-separated key, such as `awsSecret("db-config#db.host")`. An exact top-level key with the same name still takes precedence.
3 changes: 2 additions & 1 deletion packages/plugins/aws-secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This package is a [Varlock](https://varlock.dev) [plugin](https://varlock.dev/gu
- **IAM role support** - No credentials needed for AWS-hosted apps (EC2, ECS, Lambda, etc.)
- **AWS CLI authentication** - Works seamlessly with `aws configure` for local development
- **Auto-infer secret/parameter names** from environment variable names (uses name as-is)
- **JSON key extraction** from secrets/parameters using `#` syntax or named `key` parameter
- **JSON key extraction** from secrets/parameters using `#` syntax or named `key` parameter, including dot-separated paths into nested objects
- **Name prefixing** with `namePrefix` option for organized secret management
- **OIDC workload identity** - Authenticate from Vercel, GitHub Actions, and other platforms without long-lived credentials
- Support for named AWS profiles
Expand Down Expand Up @@ -225,6 +225,7 @@ Fetch a secret from AWS Secrets Manager.

- Name: `"my-secret"`
- Name with JSON key: `"my-secret#password"` (shorthand for key extraction)
- Name with a nested JSON key: `"my-secret#db.host"` (dot-separated path into nested objects)
- ARN: `"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret-AbCdEf"`

#### `awsParam()`
Expand Down
33 changes: 33 additions & 0 deletions packages/plugins/aws-secrets/src/json-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** Result of looking up a key in parsed JSON. */
export type JsonKeyLookup = | { found: true, value: string }
| { found: false, availableKeys: Array<string> };

function isContainer(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function toStringValue(value: unknown): string {
return isContainer(value) ? JSON.stringify(value) : String(value);
}

/**
* Looks up `key` in a parsed JSON value.
*
* An exact top-level key wins, so keys that contain dots keep working. Otherwise `key` is read as a
* dot-separated path: `db.host` reads `value.db.host`. Object and array results are returned as JSON text.
*/
export function lookupJsonKey(parsed: unknown, key: string): JsonKeyLookup {
if (isContainer(parsed) && Object.prototype.hasOwnProperty.call(parsed, key)) {
return { found: true, value: toStringValue(parsed[key]) };
}

let current: unknown = parsed;
for (const segment of key.split('.')) {
if (!isContainer(current) || !Object.prototype.hasOwnProperty.call(current, segment)) {
return { found: false, availableKeys: isContainer(current) ? Object.keys(current) : [] };
}
current = current[segment];
}

return { found: true, value: toStringValue(current) };
}
35 changes: 19 additions & 16 deletions packages/plugins/aws-secrets/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl,
} from 'varlock/plugin-lib';
import { BUNDLED_ICONS } from './bundled-icons.gen';
import { lookupJsonKey } from './json-key';

import { createHash } from 'node:crypto';
import {
Expand Down Expand Up @@ -469,38 +470,40 @@ class AwsPluginInstance {

extractJsonKeyFromSecret(secretValue: string, jsonKey?: string): string {
if (!jsonKey) return secretValue;
let parsed: unknown;
try {
const parsed = JSON.parse(secretValue);
if (!(jsonKey in parsed)) {
throw new ResolutionError(`Key "${jsonKey}" not found in secret JSON`, {
tip: `Available keys: ${Object.keys(parsed).join(', ')}`,
});
}
return String(parsed[jsonKey]);
parsed = JSON.parse(secretValue);
} catch (err) {
if (err instanceof ResolutionError) throw err;
throw new ResolutionError(`Failed to parse secret as JSON: ${err instanceof Error ? err.message : String(err)}`, {
tip: 'Ensure the secret value is valid JSON when extracting a specific key',
});
}
const result = lookupJsonKey(parsed, jsonKey);
if (!result.found) {
throw new ResolutionError(`Key "${jsonKey}" not found in secret JSON`, {
tip: `Available keys: ${result.availableKeys.join(', ')}`,
});
}
return result.value;
}

extractJsonKeyFromParameter(paramValue: string, jsonKey?: string): string {
if (!jsonKey) return paramValue;
let parsed: unknown;
try {
const parsed = JSON.parse(paramValue);
if (!(jsonKey in parsed)) {
throw new ResolutionError(`Key "${jsonKey}" not found in parameter JSON`, {
tip: `Available keys: ${Object.keys(parsed).join(', ')}`,
});
}
return String(parsed[jsonKey]);
parsed = JSON.parse(paramValue);
} catch (err) {
if (err instanceof ResolutionError) throw err;
throw new ResolutionError(`Failed to parse parameter as JSON: ${err instanceof Error ? err.message : String(err)}`, {
tip: 'Ensure the parameter value is valid JSON when using the # syntax for key extraction',
});
}
const result = lookupJsonKey(parsed, jsonKey);
if (!result.found) {
throw new ResolutionError(`Key "${jsonKey}" not found in parameter JSON`, {
tip: `Available keys: ${result.availableKeys.join(', ')}`,
});
}
return result.value;
}
}

Expand Down
60 changes: 60 additions & 0 deletions packages/plugins/aws-secrets/test/json-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';
import { lookupJsonKey } from '../src/json-key';

describe('lookupJsonKey', () => {
test('reads a top-level key', () => {
expect(lookupJsonKey({ host: 'db.example.com' }, 'host')).toEqual({ found: true, value: 'db.example.com' });
});

test('reads a dot-separated path into nested objects', () => {
const parsed = { db: { host: 'db.example.com', port: 5432 } };
expect(lookupJsonKey(parsed, 'db.host')).toEqual({ found: true, value: 'db.example.com' });
expect(lookupJsonKey(parsed, 'db.port')).toEqual({ found: true, value: '5432' });
});

test('reads deeper paths', () => {
expect(lookupJsonKey({ a: { b: { c: 'deep' } } }, 'a.b.c')).toEqual({ found: true, value: 'deep' });
});

test('an exact top-level key wins over a path with the same text', () => {
const parsed = { 'db.host': 'flat', db: { host: 'nested' } };
expect(lookupJsonKey(parsed, 'db.host')).toEqual({ found: true, value: 'flat' });
});

test('reports the keys at the level where the path stops', () => {
const parsed = { db: { host: 'x', port: 1 } };
expect(lookupJsonKey(parsed, 'db.user')).toEqual({ found: false, availableKeys: ['host', 'port'] });
expect(lookupJsonKey(parsed, 'cache.host')).toEqual({ found: false, availableKeys: ['db'] });
});

test('a path through a scalar is not found', () => {
expect(lookupJsonKey({ db: 'postgres' }, 'db.host')).toEqual({ found: false, availableKeys: [] });
});

test('empty path segments are not found', () => {
expect(lookupJsonKey({ a: { b: 1 } }, 'a..b').found).toBe(false);
expect(lookupJsonKey({ a: { b: 1 } }, 'a.').found).toBe(false);
});

test('inherited properties are not found', () => {
expect(lookupJsonKey({}, 'toString').found).toBe(false);
expect(lookupJsonKey({ db: {} }, 'db.constructor').found).toBe(false);
});

test('returns object and array values as JSON text', () => {
const parsed = { db: { hosts: ['a', 'b'], opts: { ssl: true } } };
expect(lookupJsonKey(parsed, 'db.hosts')).toEqual({ found: true, value: '["a","b"]' });
expect(lookupJsonKey(parsed, 'db.opts')).toEqual({ found: true, value: '{"ssl":true}' });
});

test('keeps scalar string conversion for numbers, booleans, and null', () => {
const parsed = { port: 5432, enabled: false, missing: null };
expect(lookupJsonKey(parsed, 'port')).toEqual({ found: true, value: '5432' });
expect(lookupJsonKey(parsed, 'enabled')).toEqual({ found: true, value: 'false' });
expect(lookupJsonKey(parsed, 'missing')).toEqual({ found: true, value: 'null' });
});

test('reads a path that starts at an array index', () => {
expect(lookupJsonKey([{ host: 'first' }], '0.host')).toEqual({ found: true, value: 'first' });
});
});
23 changes: 23 additions & 0 deletions packages/varlock-website/src/content/docs/plugins/aws-secrets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,29 @@ DB_PASSWORD=awsSecret("database-creds#password")
DB_PORT=awsSecret("database-creds", key="port")
```

#### Nested JSON keys

Use a dot-separated path to read a value inside a nested object. Both the `#` shorthand and the `key` parameter accept paths. If `api/config` contains:

```json
{
"db": { "host": "db.example.internal", "port": 5432 },
"featureFlags": { "newSearch": false }
}
```

```env-spec title=".env.schema"
DB_HOST=awsSecret("api/config#db.host")
# @type=number
DB_PORT=awsSecret("api/config", key="db.port")
# @type=boolean
NEW_SEARCH=awsSecret("api/config#featureFlags.newSearch")
```

- An exact top-level key wins over a path with the same text, so existing keys that contain dots (e.g. `"db.host"`) keep working.
- Object and array values are returned as JSON text, e.g. `#db` returns `{"host":"db.example.internal","port":5432}`.
- If a path doesn't exist, the error lists the keys available at the level where the lookup stopped.

### Name prefixing

Use `namePrefix` to automatically prefix all secret/parameter names for better organization:
Expand Down
Loading