Skip to content

Commit ca72529

Browse files
authored
Merge pull request #1 from qteab/module-setup
feat: initial module setup
2 parents e09853d + 009344e commit ca72529

14 files changed

+941
-29
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# @qte/nest-google-secret-manager
2+
A module for reading Google Secret Manager secrets in Nest.
3+
4+
## Installation
5+
6+
```bash
7+
$ yarn add @qte/nest-google-secret-manager
8+
```
9+
10+
## Quick start
11+
Import and use the module with a list of secrets to preload.
12+
```ts
13+
SecretManagerModule.register({
14+
secrets: ['project/some-project/secrets/some-secret/versions/latest'],
15+
})
16+
```
17+
18+
Use the `SecretManagerService` to get any preloaded secret, or to dynamically load more secrets.
19+
20+
## License
21+
@qte/nest-google-secret-manager is [MIT licensed](LICENSE).

lib/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const SECRET_MANAGER_PRELOADED_DATA_TOKEN = Symbol('SECRET_MANAGER_PRELOADED_DATA_TOKEN')
2+
export const SECRET_MANAGER_CLIENT_TOKEN = Symbol('SECRET_MANAGER_CLIENT_TOKEN')
3+
export const SECRET_MANAGER_OPTIONS = Symbol('SECRET_MANAGER_OPTIONS')

lib/exceptions/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export class SecretNotLoadedException extends Error {}
2+
export class SecretLoadException extends Error {}

lib/index.spec.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

lib/index.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
const sum = (...numbers: number[]) => {
2-
return numbers.reduce((tot, curr) => tot + curr, 0)
3-
}
4-
5-
export { sum }
1+
export { SecretManagerModule } from './secretmanager.module'
2+
export { SecretManagerService } from './secretmanager.service'
3+
export * from './exceptions'

lib/interfaces/options.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { InjectionToken, OptionalFactoryDependency } from '@nestjs/common'
2+
3+
export interface SecretManagerOptions {
4+
secrets?: string[]
5+
keyFile?: string
6+
}
7+
8+
export interface AsyncSecretManagerOptions extends SecretManagerOptions {
9+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
10+
useFactory?: (...args: any[]) => SecretManagerOptions
11+
inject?: (InjectionToken | OptionalFactoryDependency)[] | undefined
12+
}

lib/secretloader.service.spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Test } from '@nestjs/testing'
2+
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
3+
import { SecretLoaderService } from './secretloader.service'
4+
import { SecretLoadException } from './exceptions'
5+
import { SECRET_MANAGER_CLIENT_TOKEN } from './constants'
6+
7+
jest.mock('@google-cloud/secret-manager')
8+
describe('SecretLoaderService', () => {
9+
describe('static', () => {
10+
it('Can load 1 secret statically', async () => {
11+
const client = new SecretManagerServiceClient()
12+
;(client.accessSecretVersion as jest.Mock).mockImplementationOnce(({ name }) => [
13+
{
14+
payload: { data: name },
15+
},
16+
])
17+
18+
const secrets = ['project/some-project/secrets/some-secret/versions/latest']
19+
const values = await SecretLoaderService.staticLoadSecrets(client, secrets)
20+
expect(client.accessSecretVersion).toBeCalledTimes(1)
21+
expect(values).toEqual({
22+
'project/some-project/secrets/some-secret/versions/latest': 'project/some-project/secrets/some-secret/versions/latest',
23+
})
24+
})
25+
26+
it('Can load 0 secrets statically', async () => {
27+
const client = new SecretManagerServiceClient()
28+
;(client.accessSecretVersion as jest.Mock).mockImplementationOnce(({ name }) => [
29+
{
30+
payload: { data: name },
31+
},
32+
])
33+
34+
const secrets: string[] = []
35+
const values = await SecretLoaderService.staticLoadSecrets(client, secrets)
36+
expect(client.accessSecretVersion).toBeCalledTimes(0)
37+
expect(values).toEqual({})
38+
})
39+
40+
it('Can load 2 secrets statically', async () => {
41+
const client = new SecretManagerServiceClient()
42+
;(client.accessSecretVersion as jest.Mock).mockImplementation(({ name }) => [
43+
{
44+
payload: { data: name },
45+
},
46+
])
47+
48+
const secrets: string[] = ['project/some-project/secrets/some-secret/versions/latest', 'project/some-project/secrets/some-secret/versions/1']
49+
const values = await SecretLoaderService.staticLoadSecrets(client, secrets)
50+
expect(client.accessSecretVersion).toBeCalledTimes(2)
51+
expect(values).toEqual({
52+
'project/some-project/secrets/some-secret/versions/latest': 'project/some-project/secrets/some-secret/versions/latest',
53+
'project/some-project/secrets/some-secret/versions/1': 'project/some-project/secrets/some-secret/versions/1',
54+
})
55+
})
56+
57+
it('Overrides result object if the same secret is loaded twice', async () => {
58+
const client = new SecretManagerServiceClient()
59+
;(client.accessSecretVersion as jest.Mock).mockImplementation(({ name }) => [
60+
{
61+
payload: { data: name },
62+
},
63+
])
64+
65+
const secrets: string[] = [
66+
'project/some-project/secrets/some-secret/versions/latest',
67+
'project/some-project/secrets/some-secret/versions/latest',
68+
]
69+
const values = await SecretLoaderService.staticLoadSecrets(client, secrets)
70+
expect(client.accessSecretVersion).toBeCalledTimes(2)
71+
expect(values).toEqual({
72+
'project/some-project/secrets/some-secret/versions/latest': 'project/some-project/secrets/some-secret/versions/latest',
73+
})
74+
})
75+
76+
it('Throws an error if secret load fails', async () => {
77+
const client = new SecretManagerServiceClient()
78+
;(client.accessSecretVersion as jest.Mock).mockImplementation(() => {
79+
throw new SecretLoadException('Load failed')
80+
})
81+
82+
const secrets: string[] = ['project/some-project/secrets/some-secret/versions/latest']
83+
await expect(SecretLoaderService.staticLoadSecrets(client, secrets)).rejects.toThrow(SecretLoadException)
84+
})
85+
})
86+
87+
describe('dynamic', () => {
88+
let secretLoadService: SecretLoaderService
89+
let client: SecretManagerServiceClient
90+
beforeEach(async () => {
91+
client = new SecretManagerServiceClient()
92+
;(client.accessSecretVersion as jest.Mock).mockImplementation(({ name }) => [
93+
{
94+
payload: { data: name },
95+
},
96+
])
97+
const moduleRef = await Test.createTestingModule({
98+
providers: [
99+
SecretLoaderService,
100+
{
101+
provide: SECRET_MANAGER_CLIENT_TOKEN,
102+
useValue: client,
103+
},
104+
],
105+
}).compile()
106+
secretLoadService = moduleRef.get<SecretLoaderService>(SecretLoaderService)
107+
})
108+
109+
it('Can load a secret dynamically', async () => {
110+
const secret = 'project/some-project/secrets/some-secret/versions/latest'
111+
const value = await secretLoadService.loadSecret(secret)
112+
expect(secret).toEqual(value)
113+
expect(client.accessSecretVersion).toHaveBeenCalledTimes(1)
114+
})
115+
})
116+
})

lib/secretloader.service.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Injectable, Inject } from '@nestjs/common'
2+
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
3+
import { SECRET_MANAGER_CLIENT_TOKEN } from './constants'
4+
import { SecretLoadException } from './exceptions'
5+
6+
@Injectable()
7+
export class SecretLoaderService {
8+
private readonly client: SecretManagerServiceClient
9+
constructor(@Inject(SECRET_MANAGER_CLIENT_TOKEN) client: SecretManagerServiceClient) {
10+
this.client = client
11+
}
12+
13+
public async loadSecret(secret: string) {
14+
return SecretLoaderService.staticLoadSecret(this.client, secret)
15+
}
16+
17+
private static async staticLoadSecret(client: SecretManagerServiceClient, secret: string) {
18+
try {
19+
const secretData = await client.accessSecretVersion({
20+
name: secret,
21+
})
22+
const value = secretData[0].payload?.data?.toString()
23+
if (!value) {
24+
throw new SecretLoadException('Secret has no value')
25+
}
26+
return value
27+
} catch (e) {
28+
if (e instanceof Error) {
29+
throw new SecretLoadException(e.message)
30+
}
31+
throw new SecretLoadException('Unknown error')
32+
}
33+
}
34+
35+
public static async staticLoadSecrets(client: SecretManagerServiceClient, secrets: string[]) {
36+
const promises = secrets.map((secret) => this.staticLoadSecret(client, secret))
37+
const secretValues = await Promise.all(promises)
38+
39+
const secretObject = secrets.reduce((obj, key, index) => {
40+
return {
41+
...obj,
42+
[key]: secretValues[index],
43+
}
44+
}, {})
45+
return secretObject
46+
}
47+
}

lib/secretmanager.module.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Test } from '@nestjs/testing'
2+
import { Module, Global } from '@nestjs/common'
3+
import { SecretManagerModule, SecretManagerService } from './index'
4+
import { SECRET_MANAGER_CLIENT_TOKEN } from './constants'
5+
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
6+
7+
const SECRET_CONFIG_MODULE = 'SECRET_CONFIG_MODULE'
8+
@Module({
9+
providers: [
10+
{
11+
provide: SECRET_CONFIG_MODULE,
12+
useValue: {
13+
secrets: ['project/some-project/secrets/some-secret/versions/latest'],
14+
},
15+
},
16+
],
17+
exports: [SECRET_CONFIG_MODULE],
18+
})
19+
@Global()
20+
class ConfigModule {}
21+
22+
jest.mock('@google-cloud/secret-manager')
23+
describe('SecretManagerModule', () => {
24+
let client: SecretManagerServiceClient
25+
26+
beforeEach(() => {
27+
jest.resetAllMocks()
28+
client = new SecretManagerServiceClient()
29+
;(client.accessSecretVersion as jest.Mock).mockImplementationOnce(({ name }) => [
30+
{
31+
payload: { data: name },
32+
},
33+
])
34+
})
35+
36+
describe('Static creation', () => {
37+
it('Can create a module and load the provided secrets', async () => {
38+
const moduleRef = await Test.createTestingModule({
39+
imports: [
40+
SecretManagerModule.register({
41+
secrets: ['project/some-project/secrets/some-secret/versions/latest'],
42+
}),
43+
],
44+
})
45+
.overrideProvider(SECRET_MANAGER_CLIENT_TOKEN)
46+
.useValue(client)
47+
.compile()
48+
const secretManagerService = moduleRef.get<SecretManagerService>(SecretManagerService)
49+
expect(secretManagerService.getSecrets()).toEqual({
50+
'project/some-project/secrets/some-secret/versions/latest': 'project/some-project/secrets/some-secret/versions/latest',
51+
})
52+
})
53+
})
54+
55+
describe('Factory creation', () => {
56+
it('Can create a module and load the provided secrets', async () => {
57+
const moduleRef = await Test.createTestingModule({
58+
imports: [
59+
SecretManagerModule.register({
60+
useFactory: ({ secrets }: { secrets: string[] }) => {
61+
return {
62+
secrets: secrets,
63+
}
64+
},
65+
inject: [{ token: SECRET_CONFIG_MODULE, optional: false }],
66+
}),
67+
ConfigModule,
68+
],
69+
})
70+
.overrideProvider(SECRET_MANAGER_CLIENT_TOKEN)
71+
.useValue(client)
72+
.compile()
73+
const secretManagerService = moduleRef.get<SecretManagerService>(SecretManagerService)
74+
expect(secretManagerService.getSecrets()).toEqual({
75+
'project/some-project/secrets/some-secret/versions/latest': 'project/some-project/secrets/some-secret/versions/latest',
76+
})
77+
})
78+
})
79+
})

lib/secretmanager.module.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { DynamicModule, Module } from '@nestjs/common'
2+
import { SECRET_MANAGER_PRELOADED_DATA_TOKEN, SECRET_MANAGER_CLIENT_TOKEN, SECRET_MANAGER_OPTIONS } from './constants'
3+
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
4+
import { AsyncSecretManagerOptions, SecretManagerOptions } from './interfaces/options'
5+
import { SecretLoaderService } from './secretloader.service'
6+
import { SecretManagerService } from './secretmanager.service'
7+
8+
@Module({})
9+
export class SecretManagerModule {
10+
static async register(options: AsyncSecretManagerOptions): Promise<DynamicModule> {
11+
return {
12+
module: SecretManagerModule,
13+
global: true,
14+
providers: [
15+
{
16+
provide: SECRET_MANAGER_CLIENT_TOKEN,
17+
useFactory: (options: SecretManagerOptions) => {
18+
return new SecretManagerServiceClient({ keyFile: options.keyFile })
19+
},
20+
inject: [SECRET_MANAGER_OPTIONS],
21+
},
22+
{
23+
provide: SECRET_MANAGER_OPTIONS,
24+
useFactory: options.useFactory
25+
? options.useFactory
26+
: (): SecretManagerOptions => {
27+
return {
28+
secrets: options.secrets || [],
29+
}
30+
},
31+
inject: options.inject,
32+
},
33+
{
34+
provide: SECRET_MANAGER_PRELOADED_DATA_TOKEN,
35+
useFactory: (client: SecretManagerServiceClient, options: SecretManagerOptions) => {
36+
return SecretLoaderService.staticLoadSecrets(client, options.secrets || [])
37+
},
38+
inject: [SECRET_MANAGER_CLIENT_TOKEN, SECRET_MANAGER_OPTIONS],
39+
},
40+
SecretLoaderService,
41+
SecretManagerService,
42+
],
43+
exports: [SecretManagerService],
44+
}
45+
}
46+
}

0 commit comments

Comments
 (0)