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
10 changes: 10 additions & 0 deletions .changeset/grouped-secret-facts.md
Original file line number Diff line number Diff line change
@@ -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
38 changes: 37 additions & 1 deletion packages/base-ops/src/__tests__/fact-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<SecretGroupFact>(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<SimpleSecretFact>(SIMPLE_SECRET_KIND, 'standalone').spec.value).toBe('val')
expect(pool.requireFact<SecretGroupFact>(SIMPLE_SECRET_KIND, 'netbird').spec.apiKey).toBe('key1')
})
})
108 changes: 108 additions & 0 deletions packages/base-ops/src/__tests__/secret-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
18 changes: 18 additions & 0 deletions packages/base-ops/src/fact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>

/** A fact that holds multiple named secret values under a single group name. */
export type SecretGroupFact = InfrastructureFact<typeof SIMPLE_SECRET_KIND, SecretGroupSpec>

/** Create a grouped secret fact with multiple named properties. */
export function secretGroup(name: string, properties: Record<string, string>, owner: string): SecretGroupFact {
return {
kind: SIMPLE_SECRET_KIND,
metadata: { name },
spec: properties,
owner,
}
}
53 changes: 52 additions & 1 deletion packages/base-ops/src/facts-pool.ts
Original file line number Diff line number Diff line change
@@ -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<TFacts extends InfrastructureFact = InfrastructureFact> {
private readonly factsMap: Map<string, TFacts> = new Map<string, TFacts>()
private readonly factsByKindMap: Map<string, TFacts[]> = new Map<string, TFacts[]>()
Expand Down Expand Up @@ -55,6 +74,38 @@ export class InfrastructureFactsPool<TFacts extends InfrastructureFact = Infrast
return facts.filter((x) => 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<string, unknown>)[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<string, InfrastructureFactLabelValue>): boolean {
for (const [key, value] of Object.entries(labels)) {
const factLabelValue = fact.metadata.labels?.[key]
Expand Down
1 change: 1 addition & 0 deletions packages/base-ops/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './fact'
export * from './secret-resolver'
export * from './config'
export * from './config-reader'
export * from './config-merger'
Expand Down
22 changes: 22 additions & 0 deletions packages/base-ops/src/secret-resolver.ts
Original file line number Diff line number Diff line change
@@ -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)
}
69 changes: 65 additions & 4 deletions packages/vault-fact-store/src/__tests__/vault-fact-store.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
})

Expand All @@ -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/']
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading