diff --git a/src/index.ts b/src/index.ts index e3c8b25..c2ec6db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -200,8 +200,8 @@ export default class Phase { ); // Fetcher for resolving references - const fetcher: SecretFetcher = async (envName, path, key) => { - const cacheKey = normalizeKey(envName, path, key); + const fetcher: SecretFetcher = async (envName, path, key, appName) => { + const cacheKey = normalizeKey(envName, path, key, appName || undefined); if (cache.has(cacheKey)) { return { @@ -222,18 +222,51 @@ export default class Phase { let secret = secretLookup.get(cacheKey); if (!secret) { - const crossEnvSecrets = await this.get({ - ...options, - envName, - path, - key, - tags: undefined, - }); - secret = crossEnvSecrets.find((s) => s.key === key); - if (!secret) - throw new Error(`Missing secret: ${envName}:${path}:${key}`); - - secretLookup.set(cacheKey, secret); + try { + // For cross-app references, find the target app ID + let targetAppId = options.appId; + if (appName) { + // Check if appName might be an ID first + const appById = this.apps.find(a => a.id === appName); + if (appById) { + targetAppId = appName; // It was an ID, use it directly + } else { + // Treat appName as a name and check for duplicates + const matchingApps = this.apps.filter(a => a.name === appName); + + if (matchingApps.length === 0) { + // No app found by ID or name + throw new Error(`App not found: '${appName}'. Please check the app name or ID and ensure your token has access.`); + } else if (matchingApps.length > 1) { + // Found multiple apps with the same name - ambiguous! + const appDetails = matchingApps.map(a => `'${a.name}' (ID: ${a.id})`).join(', '); + throw new Error(`Ambiguous app name: '${appName}'. Multiple apps found: ${appDetails}.`); + } else { + // Found exactly one app by name + targetAppId = matchingApps[0].id; + } + } + } + + // Fetch the secret from the target app/environment + const crossEnvSecrets = await this.get({ + appId: targetAppId, + envName, + path, + key, + tags: undefined, + }); + + secret = crossEnvSecrets.find(s => s.key === key); + if (!secret) { + throw new Error(`Secret not found: ${key} in ${envName}${path !== '/' ? `, path ${path}` : ''}${appName ? `, app ${appName}` : ''}`); + } + + secretLookup.set(cacheKey, secret); + } catch (error: any) { + const msg = error.message || String(error); + throw new Error(`Failed to resolve reference: ${msg}`); + } } cache.set(cacheKey, secret.value); @@ -249,6 +282,7 @@ export default class Phase { options.envName, options.path || "/", fetcher, + null, cache ), })) diff --git a/src/utils/secretReferencing.ts b/src/utils/secretReferencing.ts index f88c535..bd08424 100644 --- a/src/utils/secretReferencing.ts +++ b/src/utils/secretReferencing.ts @@ -1,23 +1,25 @@ import { Secret } from "../types"; type SecretReference = { + app: string | null; env: string | null; - path: string | null; + path: string; key: string; }; export type SecretFetcher = ( env: string, path: string, - key: string + key: string, + app?: string | null ) => Promise; // Regex pattern for secret references const REFERENCE_REGEX = - /\${(?:(?[^.\/}]+)\.)?(?:(?[^}]+)\/)?(?[^}]+)}/g; + /\${(?:(?[^:}]+)::)?(?:(?[^.\/}]+)\.)?(?:(?[^}]+)\/)?(?[^}]+)}/g; -export const normalizeKey = (env: string, path: string, key: string) => - `${env.toLowerCase()}:${path.replace(/\/+$/, "")}:${key}`; +export const normalizeKey = (env: string, path: string, key: string, app?: string) => + `${app ? `${app}:` : ''}${env.toLowerCase()}:${path.replace(/\/+$/, "")}:${key}`; export function parseSecretReference(reference: string): SecretReference { const match = new RegExp(REFERENCE_REGEX.source).exec(reference); @@ -25,12 +27,13 @@ export function parseSecretReference(reference: string): SecretReference { throw new Error(`Invalid secret reference format: ${reference}`); } - let { env, path, key } = match.groups; - env = env?.trim() || ""; - key = key.trim(); - path = path ? `/${path.replace(/\.+/g, "/")}`.replace(/\/+/g, "/") : "/"; + const { app: appMatch, env: envMatch, path: pathMatch, key: keyMatch } = match.groups; + const app = appMatch?.trim() || null; + const env = envMatch?.trim() || null; + const key = keyMatch.trim(); + const path = pathMatch ? `/${pathMatch.replace(/\.+/g, "/")}`.replace(/\/+/g, "/") : "/"; - return { env, path, key }; + return { app, env, path, key }; } export async function resolveSecretReferences( @@ -38,49 +41,77 @@ export async function resolveSecretReferences( currentEnv: string, currentPath: string, fetcher: SecretFetcher, - cache: Map = new Map(), - resolutionStack: Set = new Set() + currentApp?: string | null, + cache = new Map(), + resolutionStack = new Set() ): Promise { + // Skip processing if there are no references to resolve + if (!value.includes("${")) { + return value; + } + const references = Array.from(value.matchAll(REFERENCE_REGEX)); let resolvedValue = value; for (const ref of references) { try { const { + app: refApp, env: refEnv, path: refPath, key: refKey, } = parseSecretReference(ref[0]); + + const targetApp = refApp || currentApp; const targetEnv = refEnv || currentEnv; - const targetPath = refPath || currentPath; - const cacheKey = normalizeKey(targetEnv, targetPath, refKey); + const targetPath = refPath || currentPath || "/"; + + // Create cache key from normalized values + const cacheKey = normalizeKey( + targetEnv || "", + targetPath, + refKey, + targetApp || undefined + ); + // Check for circular references if (resolutionStack.has(cacheKey)) { - throw new Error(`Circular reference detected: ${cacheKey}`); + console.warn(`Circular reference detected: ${ref[0]} → ${cacheKey}`); + continue; } + // Resolve the reference if not in cache if (!cache.has(cacheKey)) { resolutionStack.add(cacheKey); try { - const secret = await fetcher(targetEnv, targetPath, refKey); + // Fetch the referenced secret + const secret = await fetcher(targetEnv || "", targetPath, refKey, targetApp); + + // Recursively resolve any references in the secret value const resolvedSecretValue = await resolveSecretReferences( secret.value, targetEnv, targetPath, fetcher, + targetApp, cache, resolutionStack ); + cache.set(cacheKey, resolvedSecretValue); + } catch (error: any) { + console.warn(`Failed to resolve reference ${ref[0]}: ${error.message || error}`); + resolutionStack.delete(cacheKey); + continue; } finally { resolutionStack.delete(cacheKey); } } + // Replace the reference with its resolved value resolvedValue = resolvedValue.replace(ref[0], cache.get(cacheKey)!); - } catch (error) { - console.error(`Error resolving reference ${ref[0]}:`, error); - throw error; + } catch (error: any) { + console.warn(`Error resolving reference ${ref[0]}: ${error.message || error}`); } } diff --git a/tests/utils/secretReferencing.test.ts b/tests/utils/secretReferencing.test.ts new file mode 100644 index 0000000..af8f051 --- /dev/null +++ b/tests/utils/secretReferencing.test.ts @@ -0,0 +1,393 @@ +import { Secret } from '../../src/types'; +import { + parseSecretReference, + resolveSecretReferences, + normalizeKey +} from '../../src/utils/secretReferencing'; + +describe('Secret Referencing Utils', () => { + describe('parseSecretReference', () => { + it('should parse local secret reference', () => { + const result = parseSecretReference('${SECRET_KEY}'); + expect(result).toEqual({ + app: null, + env: null, + path: '/', + key: 'SECRET_KEY' + }); + }); + + it('should parse local folder secret reference', () => { + const result = parseSecretReference('${/path/to/SECRET_KEY}'); + expect(result).toEqual({ + app: null, + env: null, + path: '/path/to', + key: 'SECRET_KEY' + }); + }); + + it('should parse cross-env secret reference', () => { + const result = parseSecretReference('${prod.SECRET_KEY}'); + expect(result).toEqual({ + app: null, + env: 'prod', + path: '/', + key: 'SECRET_KEY' + }); + }); + + it('should parse cross-env folder secret reference', () => { + const result = parseSecretReference('${prod./path/to/SECRET_KEY}'); + expect(result).toEqual({ + app: null, + env: 'prod', + path: '/path/to', + key: 'SECRET_KEY' + }); + }); + + it('should parse cross-app secret reference', () => { + const result = parseSecretReference('${app-name::SECRET_KEY}'); + expect(result).toEqual({ + app: 'app-name', + env: null, + path: '/', + key: 'SECRET_KEY' + }); + }); + + it('should parse cross-app folder secret reference', () => { + const result = parseSecretReference('${app-name::/path/to/SECRET_KEY}'); + expect(result).toEqual({ + app: 'app-name', + env: null, + path: '/path/to', + key: 'SECRET_KEY' + }); + }); + + it('should parse cross-app cross-env secret reference', () => { + const result = parseSecretReference('${app-name::prod.SECRET_KEY}'); + expect(result).toEqual({ + app: 'app-name', + env: 'prod', + path: '/', + key: 'SECRET_KEY' + }); + }); + + it('should parse cross-app cross-env folder secret reference', () => { + const result = parseSecretReference('${app-name::prod./path/to/SECRET_KEY}'); + expect(result).toEqual({ + app: 'app-name', + env: 'prod', + path: '/path/to', + key: 'SECRET_KEY' + }); + }); + + it('should handle invalid reference format', () => { + expect(() => parseSecretReference('invalid')).toThrow('Invalid secret reference format'); + }); + }); + + describe('normalizeKey', () => { + it('should normalize key without app', () => { + const result = normalizeKey('prod', '/path/to', 'SECRET_KEY'); + expect(result).toBe('prod:/path/to:SECRET_KEY'); + }); + + it('should normalize key with app', () => { + const result = normalizeKey('prod', '/path/to', 'SECRET_KEY', 'app-name'); + expect(result).toBe('app-name:prod:/path/to:SECRET_KEY'); + }); + + it('should handle trailing slashes in path', () => { + const result = normalizeKey('prod', '/path/to/', 'SECRET_KEY'); + expect(result).toBe('prod:/path/to:SECRET_KEY'); + }); + }); + + describe('resolveSecretReferences', () => { + const mockFetcher = jest.fn(); + const cache = new Map(); + + beforeEach(() => { + mockFetcher.mockClear(); + cache.clear(); + }); + + it('should resolve local secret reference', async () => { + mockFetcher.mockResolvedValueOnce({ + value: 'secret-value', + key: 'SECRET_KEY', + environment: 'dev', + path: '/', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const result = await resolveSecretReferences( + '${SECRET_KEY}', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('secret-value'); + expect(mockFetcher).toHaveBeenCalledWith('dev', '/', 'SECRET_KEY', null); + }); + + it('should resolve cross-env secret reference', async () => { + mockFetcher.mockResolvedValueOnce({ + value: 'prod-secret', + key: 'SECRET_KEY', + environment: 'prod', + path: '/', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const result = await resolveSecretReferences( + '${prod.SECRET_KEY}', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('prod-secret'); + expect(mockFetcher).toHaveBeenCalledWith('prod', '/', 'SECRET_KEY', null); + }); + + it('should resolve cross-app secret reference', async () => { + mockFetcher.mockResolvedValueOnce({ + value: 'app-secret', + key: 'SECRET_KEY', + environment: 'dev', + path: '/', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const result = await resolveSecretReferences( + '${app-name::SECRET_KEY}', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('app-secret'); + expect(mockFetcher).toHaveBeenCalledWith('dev', '/', 'SECRET_KEY', 'app-name'); + }); + + it('should resolve nested secret references', async () => { + // First level reference + mockFetcher.mockResolvedValueOnce({ + value: '${NESTED_KEY}', + key: 'SECRET_KEY', + environment: 'dev', + path: '/', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + // Second level reference + mockFetcher.mockResolvedValueOnce({ + value: 'final-value', + key: 'NESTED_KEY', + environment: 'dev', + path: '/', + id: '2', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const result = await resolveSecretReferences( + '${SECRET_KEY}', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('final-value'); + expect(mockFetcher).toHaveBeenCalledTimes(2); + }); + + it('should detect circular references', async () => { + // First level reference + mockFetcher.mockResolvedValueOnce({ + value: '${CIRCULAR_KEY}', + key: 'SECRET_KEY', + environment: 'dev', + path: '/', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + // Circular reference + mockFetcher.mockResolvedValueOnce({ + value: '${SECRET_KEY}', + key: 'CIRCULAR_KEY', + environment: 'dev', + path: '/', + id: '2', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const result = await resolveSecretReferences( + '${SECRET_KEY}', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('${SECRET_KEY}'); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Circular reference detected')); + consoleSpy.mockRestore(); + }); + + it('should handle multiple references in a single value', async () => { + mockFetcher + .mockResolvedValueOnce({ + value: 'first-value', + key: 'FIRST_KEY', + environment: 'dev', + path: '/', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }) + .mockResolvedValueOnce({ + value: 'second-value', + key: 'SECOND_KEY', + environment: 'dev', + path: '/', + id: '2', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const result = await resolveSecretReferences( + '${FIRST_KEY}-${SECOND_KEY}', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('first-value-second-value'); + expect(mockFetcher).toHaveBeenCalledTimes(2); + }); + + it('should handle folder paths correctly', async () => { + mockFetcher.mockResolvedValueOnce({ + value: 'folder-secret', + key: 'SECRET_KEY', + environment: 'dev', + path: '/path/to', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const result = await resolveSecretReferences( + '${/path/to/SECRET_KEY}', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('folder-secret'); + expect(mockFetcher).toHaveBeenCalledWith('dev', '/path/to', 'SECRET_KEY', null); + }); + + it('should handle mixed references with literals', async () => { + mockFetcher.mockResolvedValueOnce({ + value: 'secret-value', + key: 'SECRET_KEY', + environment: 'dev', + path: '/', + id: '1', + comment: '', + tags: [], + keyDigest: '', + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1 + }); + + const result = await resolveSecretReferences( + 'prefix-${SECRET_KEY}-suffix', + 'dev', + '/', + mockFetcher, + null, + cache + ); + + expect(result).toBe('prefix-secret-value-suffix'); + }); + }); +}); \ No newline at end of file diff --git a/version.ts b/version.ts index bf61028..0332fa1 100644 --- a/version.ts +++ b/version.ts @@ -1 +1 @@ -export const LIB_VERSION = "3.0.0"; +export const LIB_VERSION = "3.1.0";