diff --git a/.changeset/grouped-secret-facts.md b/.changeset/grouped-secret-facts.md new file mode 100644 index 0000000..0fe68c4 --- /dev/null +++ b/.changeset/grouped-secret-facts.md @@ -0,0 +1,10 @@ +--- +'@opsen/base-ops': minor +'@opsen/vault-fact-store': minor +--- + +Add grouped secret facts and secret resolver to FactsPool + +- `secretGroup()` factory for defining multiple named secret values under a single group name +- `pool.getSecret()`/`pool.requireSecret()` resolve camelCase names by exact match or group+property split (e.g. `netbirdApiKey` → group `netbird`, property `apiKey`) +- Vault fact store now reads/writes grouped secret specs and coerces number values to strings diff --git a/packages/base-ops/src/__tests__/fact-store.test.ts b/packages/base-ops/src/__tests__/fact-store.test.ts index 0f182a4..c271801 100644 --- a/packages/base-ops/src/__tests__/fact-store.test.ts +++ b/packages/base-ops/src/__tests__/fact-store.test.ts @@ -3,7 +3,7 @@ import type { FactStoreReader, FactStoreWriter } from '../fact-store.js' import type { InfrastructureConfig } from '../config.js' import { InfrastructureFactsPool } from '../facts-pool.js' import { InfrastructureConfigMerger } from '../config-merger.js' -import { simpleSecret, SIMPLE_SECRET_KIND, type SimpleSecretFact } from '../fact.js' +import { simpleSecret, secretGroup, SIMPLE_SECRET_KIND, type SimpleSecretFact, type SecretGroupFact } from '../fact.js' describe('FactStoreReader', () => { it('returns facts from a mock reader', async () => { @@ -184,3 +184,39 @@ describe('SimpleSecret', () => { expect(pool.getAll(SIMPLE_SECRET_KIND)).toHaveLength(2) }) }) + +describe('SecretGroup', () => { + it('creates a grouped secret fact', () => { + const fact = secretGroup('netbird', { apiKey: 'key1', webhookSecret: 'wh1' }, 'admin') + + expect(fact).toEqual({ + kind: SIMPLE_SECRET_KIND, + metadata: { name: 'netbird' }, + spec: { apiKey: 'key1', webhookSecret: 'wh1' }, + owner: 'admin', + }) + }) + + it('works with FactsPool lookup', () => { + const config: InfrastructureConfig = { + facts: [secretGroup('netbird', { apiKey: 'key1', token: 'tok1' }, 'admin')], + } + + const pool = new InfrastructureFactsPool(config) + const group = pool.requireFact(SIMPLE_SECRET_KIND, 'netbird') + + expect(group.spec.apiKey).toBe('key1') + expect(group.spec.token).toBe('tok1') + }) + + it('coexists with simple secrets in the same pool', () => { + const config: InfrastructureConfig = { + facts: [simpleSecret('standalone', 'val', 'admin'), secretGroup('netbird', { apiKey: 'key1' }, 'admin')], + } + + const pool = new InfrastructureFactsPool(config) + expect(pool.getAll(SIMPLE_SECRET_KIND)).toHaveLength(2) + expect(pool.requireFact(SIMPLE_SECRET_KIND, 'standalone').spec.value).toBe('val') + expect(pool.requireFact(SIMPLE_SECRET_KIND, 'netbird').spec.apiKey).toBe('key1') + }) +}) diff --git a/packages/base-ops/src/__tests__/secret-resolver.test.ts b/packages/base-ops/src/__tests__/secret-resolver.test.ts new file mode 100644 index 0000000..589f246 --- /dev/null +++ b/packages/base-ops/src/__tests__/secret-resolver.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import type { InfrastructureConfig } from '../config.js' +import { InfrastructureFactsPool } from '../facts-pool.js' +import { simpleSecret, secretGroup } from '../fact.js' +import { camelCaseSplits, resolveSecret, requireSecret } from '../secret-resolver.js' + +function poolFrom(...facts: InfrastructureConfig['facts']): InfrastructureFactsPool { + return new InfrastructureFactsPool({ facts }) +} + +describe('camelCaseSplits', () => { + it('splits a two-word camelCase name', () => { + expect(camelCaseSplits('apiKey')).toEqual([{ prefix: 'api', property: 'key' }]) + }) + + it('splits a three-word camelCase name into two pairs', () => { + expect(camelCaseSplits('netbirdApiKey')).toEqual([ + { prefix: 'netbird', property: 'apiKey' }, + { prefix: 'netbirdApi', property: 'key' }, + ]) + }) + + it('returns empty array for all-lowercase name', () => { + expect(camelCaseSplits('apikey')).toEqual([]) + }) + + it('returns empty array for single character', () => { + expect(camelCaseSplits('a')).toEqual([]) + }) + + it('handles leading uppercase (acronym-style)', () => { + const splits = camelCaseSplits('awsSecretKey') + expect(splits).toEqual([ + { prefix: 'aws', property: 'secretKey' }, + { prefix: 'awsSecret', property: 'key' }, + ]) + }) + + it('handles consecutive uppercase letters', () => { + const splits = camelCaseSplits('myAWSKey') + expect(splits).toEqual([ + { prefix: 'my', property: 'aWSKey' }, + { prefix: 'myA', property: 'wSKey' }, + { prefix: 'myAW', property: 'sKey' }, + { prefix: 'myAWS', property: 'key' }, + ]) + }) +}) + +describe('pool.getSecret', () => { + it('resolves a simple secret by exact name', () => { + const pool = poolFrom(simpleSecret('netbirdApiKey', 's3cret', 'admin')) + expect(pool.getSecret('netbirdApiKey')).toBe('s3cret') + }) + + it('resolves a grouped secret property via camelCase split', () => { + const pool = poolFrom(secretGroup('netbird', { apiKey: 'key1', webhookSecret: 'wh1' }, 'admin')) + expect(pool.getSecret('netbirdApiKey')).toBe('key1') + expect(pool.getSecret('netbirdWebhookSecret')).toBe('wh1') + }) + + it('prefers exact match over group match', () => { + const pool = poolFrom( + simpleSecret('netbirdApiKey', 'exact-value', 'admin'), + secretGroup('netbird', { apiKey: 'group-value' }, 'admin2'), + ) + expect(pool.getSecret('netbirdApiKey')).toBe('exact-value') + }) + + it('returns undefined when secret not found', () => { + const pool = poolFrom(simpleSecret('other', 'val', 'admin')) + expect(pool.getSecret('netbirdApiKey')).toBeUndefined() + }) + + it('returns undefined when group exists but property does not', () => { + const pool = poolFrom(secretGroup('netbird', { token: 'tok' }, 'admin')) + expect(pool.getSecret('netbirdApiKey')).toBeUndefined() + }) + + it('tries multiple split points and matches the first valid one', () => { + const pool = poolFrom(secretGroup('netbirdApi', { key: 'from-second-split' }, 'admin')) + expect(pool.getSecret('netbirdApiKey')).toBe('from-second-split') + }) +}) + +describe('pool.requireSecret', () => { + it('returns value when secret exists', () => { + const pool = poolFrom(simpleSecret('dbPassword', 'hunter2', 'admin')) + expect(pool.requireSecret('dbPassword')).toBe('hunter2') + }) + + it('throws when secret not found', () => { + const pool = poolFrom() + expect(() => pool.requireSecret('missing')).toThrow('Secret "missing" not found') + }) +}) + +describe('standalone functions (deprecated delegates)', () => { + it('resolveSecret delegates to pool.getSecret', () => { + const pool = poolFrom(simpleSecret('key', 'val', 'admin')) + expect(resolveSecret(pool, 'key')).toBe('val') + }) + + it('requireSecret delegates to pool.requireSecret', () => { + const pool = poolFrom() + expect(() => requireSecret(pool, 'missing')).toThrow('Secret "missing" not found') + }) +}) diff --git a/packages/base-ops/src/fact.ts b/packages/base-ops/src/fact.ts index 11dc92c..d3f54e8 100644 --- a/packages/base-ops/src/fact.ts +++ b/packages/base-ops/src/fact.ts @@ -45,3 +45,21 @@ export function simpleSecret(name: string, value: string, owner: string): Simple owner, } } + +// --- Grouped Secrets --- + +/** Spec for a secret group holding multiple named properties. */ +export type SecretGroupSpec = Record + +/** A fact that holds multiple named secret values under a single group name. */ +export type SecretGroupFact = InfrastructureFact + +/** Create a grouped secret fact with multiple named properties. */ +export function secretGroup(name: string, properties: Record, owner: string): SecretGroupFact { + return { + kind: SIMPLE_SECRET_KIND, + metadata: { name }, + spec: properties, + owner, + } +} diff --git a/packages/base-ops/src/facts-pool.ts b/packages/base-ops/src/facts-pool.ts index 0e6920b..016033a 100644 --- a/packages/base-ops/src/facts-pool.ts +++ b/packages/base-ops/src/facts-pool.ts @@ -1,6 +1,25 @@ -import { InfrastructureFact, InfrastructureFactLabelValue } from './fact' +import { InfrastructureFact, InfrastructureFactLabelValue, SIMPLE_SECRET_KIND } from './fact' import { InfrastructureConfig } from './config' +export interface SecretSplit { + prefix: string + property: string +} + +/** Split a camelCase name into all possible (prefix, property) pairs. */ +export function camelCaseSplits(name: string): SecretSplit[] { + const splits: SecretSplit[] = [] + for (let i = 1; i < name.length; i++) { + if (name[i] >= 'A' && name[i] <= 'Z') { + const prefix = name.slice(0, i) + const rest = name.slice(i) + const property = rest[0].toLowerCase() + rest.slice(1) + splits.push({ prefix, property }) + } + } + return splits +} + export class InfrastructureFactsPool { private readonly factsMap: Map = new Map() private readonly factsByKindMap: Map = new Map() @@ -55,6 +74,38 @@ export class InfrastructureFactsPool this.matches(x, labels)) as T[] } + /** + * Resolve a secret by name. Tries exact match first, then camelCase group+property splits. + * Returns `undefined` if no match is found. + */ + public getSecret(name: string): string | undefined { + const direct = this.getFact(SIMPLE_SECRET_KIND, name) + if (direct && typeof direct.spec.value === 'string') { + return direct.spec.value + } + + for (const { prefix, property } of camelCaseSplits(name)) { + const group = this.getFact(SIMPLE_SECRET_KIND, prefix) + if (group) { + const value = (group.spec as Record)[property] + if (typeof value === 'string') { + return value + } + } + } + + return undefined + } + + /** Resolve a secret by name, throwing if not found. */ + public requireSecret(name: string): string { + const value = this.getSecret(name) + if (value === undefined) { + throw new Error(`Secret "${name}" not found`) + } + return value + } + private matches(fact: TFacts, labels: Record): boolean { for (const [key, value] of Object.entries(labels)) { const factLabelValue = fact.metadata.labels?.[key] diff --git a/packages/base-ops/src/index.ts b/packages/base-ops/src/index.ts index 0a1a73a..20f8aa3 100644 --- a/packages/base-ops/src/index.ts +++ b/packages/base-ops/src/index.ts @@ -1,4 +1,5 @@ export * from './fact' +export * from './secret-resolver' export * from './config' export * from './config-reader' export * from './config-merger' diff --git a/packages/base-ops/src/secret-resolver.ts b/packages/base-ops/src/secret-resolver.ts new file mode 100644 index 0000000..a1b6c4e --- /dev/null +++ b/packages/base-ops/src/secret-resolver.ts @@ -0,0 +1,22 @@ +import { InfrastructureFactsPool } from './facts-pool' + +export type { SecretSplit } from './facts-pool' +export { camelCaseSplits } from './facts-pool' + +/** + * Resolve a secret name to its string value. + * + * @deprecated Use `pool.getSecret(name)` directly instead. + */ +export function resolveSecret(pool: InfrastructureFactsPool, name: string): string | undefined { + return pool.getSecret(name) +} + +/** + * Resolve a secret name to its string value, throwing if not found. + * + * @deprecated Use `pool.requireSecret(name)` directly instead. + */ +export function requireSecret(pool: InfrastructureFactsPool, name: string): string { + return pool.requireSecret(name) +} diff --git a/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts b/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts index a7e8089..74a1aa4 100644 --- a/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts +++ b/packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { InfrastructureFact } from '@opsen/base-ops' -import { SIMPLE_SECRET_KIND, simpleSecret } from '@opsen/base-ops' +import { SIMPLE_SECRET_KIND, simpleSecret, secretGroup } from '@opsen/base-ops' import { VaultFactStore } from '../vault-fact-store.js' import { VaultKV2Client } from '../vault-client.js' @@ -181,16 +181,16 @@ describe('VaultFactStore', () => { expect(config.facts[0]).toEqual(expect.objectContaining({ metadata: { name: 'ok' } })) }) - it('skips simple secrets with non-string value', async () => { + it('skips secrets with non-scalar property values', async () => { mockClient.list.mockImplementation(async (path: string) => { if (path === 'owner') return ['secret/'] - if (path === 'owner/secret') return ['good', 'no-value', 'object-value'] + if (path === 'owner/secret') return ['good', 'object-value', 'empty'] return ['owner/'] }) mockClient.get.mockImplementation(async (path: string) => { if (path === 'owner/secret/good') return { value: 'hunter2' } - if (path === 'owner/secret/no-value') return { something: 'else' } if (path === 'owner/secret/object-value') return { value: { nested: true } } + if (path === 'owner/secret/empty') return {} return null }) @@ -203,6 +203,29 @@ describe('VaultFactStore', () => { ) }) + it('converts number values to strings when reading secrets', async () => { + mockClient.list.mockImplementation(async (path: string) => { + if (path === 'owner') return ['secret/'] + if (path === 'owner/secret') return ['port-config', 'simple-num'] + return ['owner/'] + }) + mockClient.get.mockImplementation(async (path: string) => { + if (path === 'owner/secret/port-config') return { host: 'localhost', port: 5432 } + if (path === 'owner/secret/simple-num') return { value: 42 } + return null + }) + + const store = new VaultFactStore({ address: 'https://vault', token: 'tok' }) + const config = await store.read() + + expect(config.facts).toHaveLength(2) + expect(config.facts.find((f) => f.metadata.name === 'port-config')!.spec).toEqual({ + host: 'localhost', + port: '5432', + }) + expect(config.facts.find((f) => f.metadata.name === 'simple-num')!.spec).toEqual({ value: '42' }) + }) + it('reads with basePath prefix when configured', async () => { mockClient.list.mockImplementation(async (path: string) => { if (path === 'infra') return ['my-stack/'] @@ -346,6 +369,44 @@ describe('VaultFactStore', () => { expect(mockClient.put).toHaveBeenCalledWith('manual/cluster/prod', expect.objectContaining({ kind: 'cluster' })) }) + it('reads grouped secrets with multiple string properties', async () => { + mockClient.list.mockImplementation(async (path: string) => { + if (path === 'manual') return ['secret/'] + if (path === 'manual/secret') return ['netbird'] + return ['manual/'] + }) + mockClient.get.mockImplementation(async (path: string) => { + if (path === 'manual/secret/netbird') return { apiKey: 'key1', webhookSecret: 'wh1' } + return null + }) + + const store = new VaultFactStore({ address: 'https://vault', token: 'tok' }) + const config = await store.read() + + expect(config.facts).toHaveLength(1) + expect(config.facts[0]).toEqual({ + kind: 'secret', + metadata: { name: 'netbird' }, + spec: { apiKey: 'key1', webhookSecret: 'wh1' }, + owner: 'manual', + }) + }) + + it('writes grouped secrets as raw properties', async () => { + mockClient.put.mockResolvedValue(undefined) + mockClient.list.mockResolvedValue([]) + + const store = new VaultFactStore({ address: 'https://vault', token: 'tok' }, 'manual') + await store.write({ + facts: [secretGroup('netbird', { apiKey: 'key1', webhookSecret: 'wh1' }, 'manual')], + }) + + expect(mockClient.put).toHaveBeenCalledWith('manual/secret/netbird', { + apiKey: 'key1', + webhookSecret: 'wh1', + }) + }) + it('cleans up stale simple secrets via directory listing', async () => { mockClient.put.mockResolvedValue(undefined) mockClient.delete.mockResolvedValue(undefined) diff --git a/packages/vault-fact-store/src/vault-fact-store.ts b/packages/vault-fact-store/src/vault-fact-store.ts index e9b1c95..3b0e089 100644 --- a/packages/vault-fact-store/src/vault-fact-store.ts +++ b/packages/vault-fact-store/src/vault-fact-store.ts @@ -63,12 +63,18 @@ export class VaultFactStore implements FactStoreReader, FactStoreWriter { if (!data) return if (kind === SIMPLE_SECRET_KIND) { - const value = data.value - if (typeof value === 'string') { + const entries = Object.entries(data) + const allScalar = + entries.length > 0 && entries.every(([, v]) => typeof v === 'string' || typeof v === 'number') + if (allScalar) { + const spec: Record = {} + for (const [k, v] of entries) { + spec[k] = String(v) + } facts.push({ kind: SIMPLE_SECRET_KIND, metadata: { name }, - spec: { value }, + spec, owner, }) } @@ -101,7 +107,7 @@ export class VaultFactStore implements FactStoreReader, FactStoreWriter { const writes = config.facts.map(async (fact) => { const path = factPath(this.basePath, this.owner!, fact.kind, fact.metadata.name) if (fact.kind === SIMPLE_SECRET_KIND) { - await this.client.put(path, { value: (fact.spec as { value: string }).value }) + await this.client.put(path, fact.spec as Record) } else { await this.client.put(path, fact as unknown as Record) }