@pikku/mongodb 0.12.21 → 0.12.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +9 -1
- package/dist/src/mongodb-workflow-run-service.js +3 -13
- package/dist/src/mongodb-workflow-service.d.ts +38 -22
- package/dist/src/mongodb-workflow-service.js +130 -88
- package/package.json +10 -6
- package/run-tests.sh +21 -6
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/index.ts +0 -14
- package/src/mongodb-agent-run-service.ts +0 -234
- package/src/mongodb-agent-storage-service.ts +0 -632
- package/src/mongodb-channel-store.ts +0 -109
- package/src/mongodb-deployment-service.ts +0 -136
- package/src/mongodb-eventhub-store.ts +0 -52
- package/src/mongodb-secret-service.ts +0 -232
- package/src/mongodb-services.test.ts +0 -235
- package/src/mongodb-session-store.ts +0 -50
- package/src/mongodb-workflow-run-service.ts +0 -235
- package/src/mongodb-workflow-service.test.ts +0 -238
- package/src/mongodb-workflow-service.ts +0 -687
- package/src/pikku-mongodb.ts +0 -41
- package/tsconfig.json +0 -17
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { ChannelStore } from '@pikku/core/channel'
|
|
2
|
-
import type { Channel } from '@pikku/core/channel'
|
|
3
|
-
import type { Db, Collection } from 'mongodb'
|
|
4
|
-
|
|
5
|
-
interface ChannelDoc {
|
|
6
|
-
_id: string
|
|
7
|
-
channelName: string
|
|
8
|
-
createdAt: Date
|
|
9
|
-
openingData: any
|
|
10
|
-
pikkuUserId: string | null
|
|
11
|
-
state?: unknown
|
|
12
|
-
lastWire: Date
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface ChannelSubscriptionDoc {
|
|
16
|
-
channelId: string
|
|
17
|
-
topic: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class MongoDBChannelStore extends ChannelStore {
|
|
21
|
-
private initialized = false
|
|
22
|
-
private channels!: Collection<ChannelDoc>
|
|
23
|
-
private subscriptions!: Collection<ChannelSubscriptionDoc>
|
|
24
|
-
|
|
25
|
-
constructor(private db: Db) {
|
|
26
|
-
super()
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
public async init(): Promise<void> {
|
|
30
|
-
if (this.initialized) return
|
|
31
|
-
|
|
32
|
-
this.channels = this.db.collection<ChannelDoc>('channels')
|
|
33
|
-
this.subscriptions = this.db.collection<ChannelSubscriptionDoc>(
|
|
34
|
-
'channel_subscriptions'
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
await this.subscriptions.createIndex(
|
|
38
|
-
{ channelId: 1, topic: 1 },
|
|
39
|
-
{ unique: true }
|
|
40
|
-
)
|
|
41
|
-
await this.subscriptions.createIndex({ topic: 1 })
|
|
42
|
-
|
|
43
|
-
this.initialized = true
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
public async addChannel({
|
|
47
|
-
channelId,
|
|
48
|
-
channelName,
|
|
49
|
-
openingData,
|
|
50
|
-
}: Channel): Promise<void> {
|
|
51
|
-
await this.channels.insertOne({
|
|
52
|
-
_id: channelId,
|
|
53
|
-
channelName,
|
|
54
|
-
openingData: openingData || {},
|
|
55
|
-
pikkuUserId: null,
|
|
56
|
-
createdAt: new Date(),
|
|
57
|
-
lastWire: new Date(),
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
public async removeChannels(channelIds: string[]): Promise<void> {
|
|
62
|
-
if (channelIds.length === 0) return
|
|
63
|
-
|
|
64
|
-
await this.channels.deleteMany({ _id: { $in: channelIds } })
|
|
65
|
-
await this.subscriptions.deleteMany({ channelId: { $in: channelIds } })
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
public async setPikkuUserId(
|
|
69
|
-
channelId: string,
|
|
70
|
-
pikkuUserId: string | null
|
|
71
|
-
): Promise<void> {
|
|
72
|
-
await this.channels.updateOne({ _id: channelId }, { $set: { pikkuUserId } })
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
public async getChannel(
|
|
76
|
-
channelId: string
|
|
77
|
-
): Promise<Channel & { pikkuUserId?: string }> {
|
|
78
|
-
const row = await this.channels.findOne({ _id: channelId })
|
|
79
|
-
|
|
80
|
-
if (!row) {
|
|
81
|
-
throw new Error(`Channel not found: ${channelId}`)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
channelId: row._id,
|
|
86
|
-
channelName: row.channelName,
|
|
87
|
-
openingData: row.openingData ?? {},
|
|
88
|
-
pikkuUserId: row.pikkuUserId ?? undefined,
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
public async setState(channelId: string, state: unknown): Promise<void> {
|
|
93
|
-
await this.channels.updateOne({ _id: channelId }, { $set: { state } })
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
public async getState(channelId: string): Promise<unknown | undefined> {
|
|
97
|
-
const row = await this.channels.findOne(
|
|
98
|
-
{ _id: channelId },
|
|
99
|
-
{ projection: { state: 1 } }
|
|
100
|
-
)
|
|
101
|
-
return row?.state ?? undefined
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
public async clearState(channelId: string): Promise<void> {
|
|
105
|
-
await this.channels.updateOne({ _id: channelId }, { $unset: { state: '' } })
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
public async close(): Promise<void> {}
|
|
109
|
-
}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { buildRemoteHeaders } from '@pikku/core/remote'
|
|
2
|
-
import type {
|
|
3
|
-
DeploymentService,
|
|
4
|
-
DeploymentServiceConfig,
|
|
5
|
-
DeploymentConfig,
|
|
6
|
-
} from '@pikku/core/services'
|
|
7
|
-
import type { JWTService } from '@pikku/core/services'
|
|
8
|
-
import type { SecretService } from '@pikku/core/services'
|
|
9
|
-
import { getAllFunctionNames } from '@pikku/core/function'
|
|
10
|
-
import type { Db, Collection } from 'mongodb'
|
|
11
|
-
|
|
12
|
-
interface DeploymentDoc {
|
|
13
|
-
_id: string
|
|
14
|
-
endpoint: string
|
|
15
|
-
lastHeartbeat: Date
|
|
16
|
-
createdAt: Date
|
|
17
|
-
functions: string[]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class MongoDBDeploymentService implements DeploymentService {
|
|
21
|
-
private initialized = false
|
|
22
|
-
private heartbeatTimer?: ReturnType<typeof setInterval>
|
|
23
|
-
private deploymentConfig?: DeploymentConfig
|
|
24
|
-
private heartbeatInterval: number
|
|
25
|
-
private heartbeatTtl: number
|
|
26
|
-
private deployments!: Collection<DeploymentDoc>
|
|
27
|
-
|
|
28
|
-
constructor(
|
|
29
|
-
config: DeploymentServiceConfig,
|
|
30
|
-
private db: Db,
|
|
31
|
-
private jwt?: JWTService,
|
|
32
|
-
private secrets?: SecretService
|
|
33
|
-
) {
|
|
34
|
-
this.heartbeatInterval = config.heartbeatInterval ?? 10000
|
|
35
|
-
this.heartbeatTtl = config.heartbeatTtl ?? 30000
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
public async init(): Promise<void> {
|
|
39
|
-
if (this.initialized) return
|
|
40
|
-
|
|
41
|
-
this.deployments = this.db.collection<DeploymentDoc>('pikku_deployments')
|
|
42
|
-
|
|
43
|
-
await this.deployments.createIndex({ lastHeartbeat: 1 })
|
|
44
|
-
await this.deployments.createIndex({ functions: 1 })
|
|
45
|
-
|
|
46
|
-
this.initialized = true
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async start(config: DeploymentConfig): Promise<void> {
|
|
50
|
-
const functions = config.functions ?? getAllFunctionNames()
|
|
51
|
-
this.deploymentConfig = { ...config, functions }
|
|
52
|
-
const now = new Date()
|
|
53
|
-
|
|
54
|
-
await this.deployments.updateOne(
|
|
55
|
-
{ _id: config.deploymentId },
|
|
56
|
-
{
|
|
57
|
-
$set: {
|
|
58
|
-
endpoint: config.endpoint,
|
|
59
|
-
lastHeartbeat: now,
|
|
60
|
-
functions,
|
|
61
|
-
},
|
|
62
|
-
$setOnInsert: {
|
|
63
|
-
_id: config.deploymentId,
|
|
64
|
-
createdAt: now,
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
{ upsert: true }
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
this.heartbeatTimer = setInterval(
|
|
71
|
-
() => this.sendHeartbeat(),
|
|
72
|
-
this.heartbeatInterval
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async stop(): Promise<void> {
|
|
77
|
-
if (this.heartbeatTimer) {
|
|
78
|
-
clearInterval(this.heartbeatTimer)
|
|
79
|
-
this.heartbeatTimer = undefined
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (this.deploymentConfig) {
|
|
83
|
-
await this.deployments.deleteOne({
|
|
84
|
-
_id: this.deploymentConfig.deploymentId,
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async invoke(
|
|
90
|
-
funcName: string,
|
|
91
|
-
data: unknown,
|
|
92
|
-
session?: unknown,
|
|
93
|
-
traceId?: string
|
|
94
|
-
): Promise<unknown> {
|
|
95
|
-
const headers = await buildRemoteHeaders(
|
|
96
|
-
this.jwt,
|
|
97
|
-
this.secrets,
|
|
98
|
-
funcName,
|
|
99
|
-
session,
|
|
100
|
-
traceId
|
|
101
|
-
)
|
|
102
|
-
const cutoff = new Date(Date.now() - this.heartbeatTtl)
|
|
103
|
-
const result = await this.deployments.findOne(
|
|
104
|
-
{ functions: funcName, lastHeartbeat: { $gt: cutoff } },
|
|
105
|
-
{ sort: { lastHeartbeat: -1 } }
|
|
106
|
-
)
|
|
107
|
-
if (!result) {
|
|
108
|
-
throw new Error(`No deployment found for function '${funcName}'`)
|
|
109
|
-
}
|
|
110
|
-
const url = `${result.endpoint}/remote/rpc/${encodeURIComponent(funcName)}`
|
|
111
|
-
const response = await fetch(url, {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: { 'Content-Type': 'application/json', ...headers },
|
|
114
|
-
body: JSON.stringify({ data }),
|
|
115
|
-
})
|
|
116
|
-
if (!response.ok) {
|
|
117
|
-
throw new Error(
|
|
118
|
-
`Remote RPC call to '${funcName}' failed: ${response.status}`
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
return response.json()
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private async sendHeartbeat(): Promise<void> {
|
|
125
|
-
if (!this.deploymentConfig) return
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
await this.deployments.updateOne(
|
|
129
|
-
{ _id: this.deploymentConfig.deploymentId },
|
|
130
|
-
{ $set: { lastHeartbeat: new Date() } }
|
|
131
|
-
)
|
|
132
|
-
} catch {
|
|
133
|
-
// Heartbeat failed, will retry on next interval
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { EventHubStore } from '@pikku/core/channel'
|
|
2
|
-
import type { Db, Collection } from 'mongodb'
|
|
3
|
-
|
|
4
|
-
interface ChannelSubscriptionDoc {
|
|
5
|
-
channelId: string
|
|
6
|
-
topic: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export class MongoDBEventHubStore extends EventHubStore {
|
|
10
|
-
private initialized = false
|
|
11
|
-
private subscriptions!: Collection<ChannelSubscriptionDoc>
|
|
12
|
-
|
|
13
|
-
constructor(private db: Db) {
|
|
14
|
-
super()
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
public async init(): Promise<void> {
|
|
18
|
-
if (this.initialized) return
|
|
19
|
-
this.subscriptions = this.db.collection<ChannelSubscriptionDoc>(
|
|
20
|
-
'channel_subscriptions'
|
|
21
|
-
)
|
|
22
|
-
this.initialized = true
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
public async getChannelIdsForTopic(topic: string): Promise<string[]> {
|
|
26
|
-
const result = await this.subscriptions
|
|
27
|
-
.find({ topic })
|
|
28
|
-
.project({ channelId: 1 })
|
|
29
|
-
.toArray()
|
|
30
|
-
return result.map((row) => row.channelId)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
public async subscribe(topic: string, channelId: string): Promise<boolean> {
|
|
34
|
-
try {
|
|
35
|
-
await this.subscriptions.updateOne(
|
|
36
|
-
{ channelId, topic },
|
|
37
|
-
{ $setOnInsert: { channelId, topic } },
|
|
38
|
-
{ upsert: true }
|
|
39
|
-
)
|
|
40
|
-
return true
|
|
41
|
-
} catch {
|
|
42
|
-
return false
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
public async unsubscribe(topic: string, channelId: string): Promise<boolean> {
|
|
47
|
-
const result = await this.subscriptions.deleteOne({ channelId, topic })
|
|
48
|
-
return result.deletedCount > 0
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
public async close(): Promise<void> {}
|
|
52
|
-
}
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
deriveKEK,
|
|
3
|
-
envelopeDecrypt,
|
|
4
|
-
envelopeEncrypt,
|
|
5
|
-
envelopeRewrap,
|
|
6
|
-
generateKEKSalt,
|
|
7
|
-
} from '@pikku/core/crypto-utils'
|
|
8
|
-
import { createSecretValue, isSecretValue } from '@pikku/core/classification'
|
|
9
|
-
import type { SecretService, SecretValues } from '@pikku/core/services'
|
|
10
|
-
import type { SecretValue } from '@pikku/core/classification'
|
|
11
|
-
import type { Db, Collection } from 'mongodb'
|
|
12
|
-
|
|
13
|
-
export interface MongoDBSecretServiceConfig {
|
|
14
|
-
key: string
|
|
15
|
-
keyVersion?: number
|
|
16
|
-
previousKey?: string
|
|
17
|
-
audit?: boolean
|
|
18
|
-
auditReads?: boolean
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface SecretDoc {
|
|
22
|
-
_id: string
|
|
23
|
-
ciphertext: string
|
|
24
|
-
wrappedDek: string
|
|
25
|
-
keyVersion: number
|
|
26
|
-
createdAt: Date
|
|
27
|
-
updatedAt: Date
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface KekSaltDoc {
|
|
31
|
-
_id: number
|
|
32
|
-
salt: string
|
|
33
|
-
createdAt: Date
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface SecretAuditDoc {
|
|
37
|
-
_id: string
|
|
38
|
-
secretKey: string
|
|
39
|
-
action: string
|
|
40
|
-
performedAt: Date
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export class MongoDBSecretService implements SecretService {
|
|
44
|
-
private initialized = false
|
|
45
|
-
private key: string
|
|
46
|
-
private keyVersion: number
|
|
47
|
-
private previousKey?: string
|
|
48
|
-
private audit: boolean
|
|
49
|
-
private auditReads: boolean
|
|
50
|
-
private secrets!: Collection<SecretDoc>
|
|
51
|
-
private secretsAudit!: Collection<SecretAuditDoc>
|
|
52
|
-
private kekSaltDocs!: Collection<KekSaltDoc>
|
|
53
|
-
private kekSalts = new Map<number, string>()
|
|
54
|
-
private keks = new Map<number, CryptoKey>()
|
|
55
|
-
|
|
56
|
-
constructor(
|
|
57
|
-
private db: Db,
|
|
58
|
-
config: MongoDBSecretServiceConfig
|
|
59
|
-
) {
|
|
60
|
-
this.key = config.key
|
|
61
|
-
this.keyVersion = config.keyVersion ?? 1
|
|
62
|
-
this.previousKey = config.previousKey
|
|
63
|
-
this.audit = config.audit ?? false
|
|
64
|
-
this.auditReads = config.auditReads ?? false
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
public async init(): Promise<void> {
|
|
68
|
-
if (this.initialized) return
|
|
69
|
-
|
|
70
|
-
this.secrets = this.db.collection<SecretDoc>('secrets')
|
|
71
|
-
this.secretsAudit = this.db.collection<SecretAuditDoc>('secrets_audit')
|
|
72
|
-
this.kekSaltDocs = this.db.collection<KekSaltDoc>('secret_kek_salts')
|
|
73
|
-
|
|
74
|
-
await this.secrets.createIndex({ _id: 1 })
|
|
75
|
-
|
|
76
|
-
if (this.audit) {
|
|
77
|
-
await this.secretsAudit.createIndex({ secretKey: 1 })
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
this.initialized = true
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private async logAudit(
|
|
84
|
-
secretKey: string,
|
|
85
|
-
action: 'read' | 'write' | 'delete' | 'rotate'
|
|
86
|
-
): Promise<void> {
|
|
87
|
-
if (!this.audit) return
|
|
88
|
-
if (action === 'read' && !this.auditReads) return
|
|
89
|
-
|
|
90
|
-
await this.secretsAudit.insertOne({
|
|
91
|
-
_id: crypto.randomUUID(),
|
|
92
|
-
secretKey,
|
|
93
|
-
action,
|
|
94
|
-
performedAt: new Date(),
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
private async getKEKSalt(version: number): Promise<string> {
|
|
99
|
-
const cached = this.kekSalts.get(version)
|
|
100
|
-
if (cached) return cached
|
|
101
|
-
|
|
102
|
-
const doc = await this.kekSaltDocs.findOneAndUpdate(
|
|
103
|
-
{ _id: version },
|
|
104
|
-
{ $setOnInsert: { salt: generateKEKSalt(), createdAt: new Date() } },
|
|
105
|
-
{ upsert: true, returnDocument: 'after' }
|
|
106
|
-
)
|
|
107
|
-
if (!doc?.salt) {
|
|
108
|
-
throw new Error(`Failed to persist a KEK salt for key_version ${version}`)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
this.kekSalts.set(version, doc.salt)
|
|
112
|
-
return doc.salt
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private async getKEK(version: number): Promise<CryptoKey> {
|
|
116
|
-
const cached = this.keks.get(version)
|
|
117
|
-
if (cached) return cached
|
|
118
|
-
|
|
119
|
-
const passphrase = version === this.keyVersion ? this.key : this.previousKey
|
|
120
|
-
if (!passphrase) {
|
|
121
|
-
throw new Error(`No KEK available for key_version ${version}`)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
|
|
125
|
-
this.keks.set(version, kek)
|
|
126
|
-
return kek
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
|
|
130
|
-
const row = await this.secrets.findOne({ _id: key })
|
|
131
|
-
if (!row) throw new Error(`Requested secret not found: ${key}`)
|
|
132
|
-
|
|
133
|
-
const kek = await this.getKEK(row.keyVersion)
|
|
134
|
-
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
135
|
-
await this.logAudit(key, 'read')
|
|
136
|
-
return createSecretValue(result)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async hasSecret(key: string): Promise<boolean> {
|
|
140
|
-
const count = await this.secrets.countDocuments({ _id: key }, { limit: 1 })
|
|
141
|
-
return count > 0
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async setSecret(key: string, value: unknown): Promise<void> {
|
|
145
|
-
// Encrypting the wrapper would store its redaction, not the secret —
|
|
146
|
-
// writing a secret back to the vault is exactly what this method is for.
|
|
147
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
148
|
-
await this.getKEK(this.keyVersion),
|
|
149
|
-
isSecretValue(value) ? value.reveal() : value
|
|
150
|
-
)
|
|
151
|
-
const now = new Date()
|
|
152
|
-
|
|
153
|
-
await this.secrets.updateOne(
|
|
154
|
-
{ _id: key },
|
|
155
|
-
{
|
|
156
|
-
$set: {
|
|
157
|
-
ciphertext,
|
|
158
|
-
wrappedDek: wrappedDEK,
|
|
159
|
-
keyVersion: this.keyVersion,
|
|
160
|
-
updatedAt: now,
|
|
161
|
-
},
|
|
162
|
-
$setOnInsert: {
|
|
163
|
-
_id: key,
|
|
164
|
-
createdAt: now,
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
{ upsert: true }
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
await this.logAudit(key, 'write')
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async deleteSecret(key: string): Promise<void> {
|
|
174
|
-
await this.secrets.deleteOne({ _id: key })
|
|
175
|
-
await this.logAudit(key, 'delete')
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
179
|
-
keys: (keyof T & string)[]
|
|
180
|
-
): Promise<Partial<SecretValues<T>>> {
|
|
181
|
-
const rows = await this.secrets
|
|
182
|
-
.find({ _id: { $in: keys } } as any)
|
|
183
|
-
.toArray()
|
|
184
|
-
const out: Record<string, unknown> = {}
|
|
185
|
-
for (const row of rows) {
|
|
186
|
-
try {
|
|
187
|
-
const kek = await this.getKEK(row.keyVersion)
|
|
188
|
-
out[row._id] = createSecretValue(
|
|
189
|
-
await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
|
|
190
|
-
)
|
|
191
|
-
} catch (cause) {
|
|
192
|
-
throw new Error(
|
|
193
|
-
`Failed to decrypt secret "${row._id}" (key_version ${row.keyVersion}): ` +
|
|
194
|
-
`the configured KEK does not match the key it was wrapped under`,
|
|
195
|
-
{ cause }
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return out as Partial<SecretValues<T>>
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async rotateKEK(): Promise<number> {
|
|
203
|
-
if (!this.previousKey) {
|
|
204
|
-
throw new Error('No previousKey configured — nothing to rotate from')
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const rows = await this.secrets
|
|
208
|
-
.find({ keyVersion: { $lt: this.keyVersion } })
|
|
209
|
-
.project({ _id: 1, wrappedDek: 1 })
|
|
210
|
-
.toArray()
|
|
211
|
-
|
|
212
|
-
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
213
|
-
const newKEK = await this.getKEK(this.keyVersion)
|
|
214
|
-
|
|
215
|
-
for (const row of rows) {
|
|
216
|
-
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
|
|
217
|
-
await this.secrets.updateOne(
|
|
218
|
-
{ _id: row._id },
|
|
219
|
-
{
|
|
220
|
-
$set: {
|
|
221
|
-
wrappedDek: newWrappedDEK,
|
|
222
|
-
keyVersion: this.keyVersion,
|
|
223
|
-
updatedAt: new Date(),
|
|
224
|
-
},
|
|
225
|
-
}
|
|
226
|
-
)
|
|
227
|
-
await this.logAudit(row._id as string, 'rotate')
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return rows.length
|
|
231
|
-
}
|
|
232
|
-
}
|