diff --git a/.bumpy/aws-secrets-nested-json-keys.md b/.bumpy/aws-secrets-nested-json-keys.md new file mode 100644 index 000000000..5bab44dbf --- /dev/null +++ b/.bumpy/aws-secrets-nested-json-keys.md @@ -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. diff --git a/packages/plugins/aws-secrets/README.md b/packages/plugins/aws-secrets/README.md index c970b26ed..efa886daa 100644 --- a/packages/plugins/aws-secrets/README.md +++ b/packages/plugins/aws-secrets/README.md @@ -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 @@ -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()` diff --git a/packages/plugins/aws-secrets/src/json-key.ts b/packages/plugins/aws-secrets/src/json-key.ts new file mode 100644 index 000000000..6596edb92 --- /dev/null +++ b/packages/plugins/aws-secrets/src/json-key.ts @@ -0,0 +1,33 @@ +/** Result of looking up a key in parsed JSON. */ +export type JsonKeyLookup = | { found: true, value: string } + | { found: false, availableKeys: Array }; + +function isContainer(value: unknown): value is Record { + 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) }; +} diff --git a/packages/plugins/aws-secrets/src/plugin.ts b/packages/plugins/aws-secrets/src/plugin.ts index aecc050bc..60b399c41 100644 --- a/packages/plugins/aws-secrets/src/plugin.ts +++ b/packages/plugins/aws-secrets/src/plugin.ts @@ -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 { @@ -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; } } diff --git a/packages/plugins/aws-secrets/test/json-key.test.ts b/packages/plugins/aws-secrets/test/json-key.test.ts new file mode 100644 index 000000000..8b69cebe7 --- /dev/null +++ b/packages/plugins/aws-secrets/test/json-key.test.ts @@ -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' }); + }); +}); diff --git a/packages/varlock-website/src/content/docs/plugins/aws-secrets.mdx b/packages/varlock-website/src/content/docs/plugins/aws-secrets.mdx index 28a27d30b..b9ad22094 100644 --- a/packages/varlock-website/src/content/docs/plugins/aws-secrets.mdx +++ b/packages/varlock-website/src/content/docs/plugins/aws-secrets.mdx @@ -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: