@zincapp/znvault-cli 2.1.0
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/README.md +310 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +660 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/apikey.d.ts +3 -0
- package/dist/commands/apikey.d.ts.map +1 -0
- package/dist/commands/apikey.js +767 -0
- package/dist/commands/apikey.js.map +1 -0
- package/dist/commands/audit.d.ts +3 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +147 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/auth.d.ts +3 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +426 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/cert.d.ts +3 -0
- package/dist/commands/cert.d.ts.map +1 -0
- package/dist/commands/cert.js +398 -0
- package/dist/commands/cert.js.map +1 -0
- package/dist/commands/cluster.d.ts +3 -0
- package/dist/commands/cluster.d.ts.map +1 -0
- package/dist/commands/cluster.js +228 -0
- package/dist/commands/cluster.js.map +1 -0
- package/dist/commands/emergency.d.ts +3 -0
- package/dist/commands/emergency.d.ts.map +1 -0
- package/dist/commands/emergency.js +223 -0
- package/dist/commands/emergency.js.map +1 -0
- package/dist/commands/health.d.ts +3 -0
- package/dist/commands/health.d.ts.map +1 -0
- package/dist/commands/health.js +188 -0
- package/dist/commands/health.js.map +1 -0
- package/dist/commands/lockdown.d.ts +3 -0
- package/dist/commands/lockdown.d.ts.map +1 -0
- package/dist/commands/lockdown.js +232 -0
- package/dist/commands/lockdown.js.map +1 -0
- package/dist/commands/permissions.d.ts +3 -0
- package/dist/commands/permissions.d.ts.map +1 -0
- package/dist/commands/permissions.js +168 -0
- package/dist/commands/permissions.js.map +1 -0
- package/dist/commands/policy.d.ts +3 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +660 -0
- package/dist/commands/policy.js.map +1 -0
- package/dist/commands/superadmin.d.ts +3 -0
- package/dist/commands/superadmin.d.ts.map +1 -0
- package/dist/commands/superadmin.js +203 -0
- package/dist/commands/superadmin.js.map +1 -0
- package/dist/commands/tenant.d.ts +3 -0
- package/dist/commands/tenant.d.ts.map +1 -0
- package/dist/commands/tenant.js +277 -0
- package/dist/commands/tenant.js.map +1 -0
- package/dist/commands/update.d.ts +9 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +359 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/user.d.ts +3 -0
- package/dist/commands/user.d.ts.map +1 -0
- package/dist/commands/user.js +363 -0
- package/dist/commands/user.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/client.d.ts +246 -0
- package/dist/lib/client.d.ts.map +1 -0
- package/dist/lib/client.js +734 -0
- package/dist/lib/client.js.map +1 -0
- package/dist/lib/config.d.ts +130 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +342 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/db.d.ts +111 -0
- package/dist/lib/db.d.ts.map +1 -0
- package/dist/lib/db.js +698 -0
- package/dist/lib/db.js.map +1 -0
- package/dist/lib/local.d.ts +41 -0
- package/dist/lib/local.d.ts.map +1 -0
- package/dist/lib/local.js +236 -0
- package/dist/lib/local.js.map +1 -0
- package/dist/lib/mode.d.ts +210 -0
- package/dist/lib/mode.d.ts.map +1 -0
- package/dist/lib/mode.js +389 -0
- package/dist/lib/mode.js.map +1 -0
- package/dist/lib/output.d.ts +61 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +190 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/prompts.d.ts +32 -0
- package/dist/lib/prompts.d.ts.map +1 -0
- package/dist/lib/prompts.js +96 -0
- package/dist/lib/prompts.js.map +1 -0
- package/dist/services/auto-update-daemon.d.ts +48 -0
- package/dist/services/auto-update-daemon.d.ts.map +1 -0
- package/dist/services/auto-update-daemon.js +296 -0
- package/dist/services/auto-update-daemon.js.map +1 -0
- package/dist/services/signature-verifier.d.ts +38 -0
- package/dist/services/signature-verifier.d.ts.map +1 -0
- package/dist/services/signature-verifier.js +209 -0
- package/dist/services/signature-verifier.js.map +1 -0
- package/dist/services/update-checker.d.ts +39 -0
- package/dist/services/update-checker.d.ts.map +1 -0
- package/dist/services/update-checker.js +198 -0
- package/dist/services/update-checker.js.map +1 -0
- package/dist/services/update-installer.d.ts +54 -0
- package/dist/services/update-installer.d.ts.map +1 -0
- package/dist/services/update-installer.js +360 -0
- package/dist/services/update-installer.js.map +1 -0
- package/dist/types/index.d.ts +411 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/update.d.ts +137 -0
- package/dist/types/update.d.ts.map +1 -0
- package/dist/types/update.js +27 -0
- package/dist/types/update.js.map +1 -0
- package/dist/utils/platform.d.ts +35 -0
- package/dist/utils/platform.d.ts.map +1 -0
- package/dist/utils/platform.js +115 -0
- package/dist/utils/platform.js.map +1 -0
- package/package.json +59 -0
package/dist/lib/db.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import bcryptjs from 'bcryptjs';
|
|
3
|
+
import { getLocalConfig } from './local.js';
|
|
4
|
+
const { Client } = pg;
|
|
5
|
+
/**
|
|
6
|
+
* Database client for direct PostgreSQL operations.
|
|
7
|
+
* Used for local mode (running on vault nodes) and emergency operations.
|
|
8
|
+
*/
|
|
9
|
+
export class LocalDBClient {
|
|
10
|
+
client;
|
|
11
|
+
connected = false;
|
|
12
|
+
constructor() {
|
|
13
|
+
const config = getLocalConfig();
|
|
14
|
+
if (!config) {
|
|
15
|
+
throw new Error('Database configuration not available.\n' +
|
|
16
|
+
'Either set DATABASE_URL environment variable or run with sudo on a vault node.');
|
|
17
|
+
}
|
|
18
|
+
this.client = new Client({
|
|
19
|
+
connectionString: config.databaseUrl,
|
|
20
|
+
ssl: config.databaseSsl ? { rejectUnauthorized: false } : false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async connect() {
|
|
24
|
+
if (!this.connected) {
|
|
25
|
+
await this.client.connect();
|
|
26
|
+
this.connected = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async close() {
|
|
30
|
+
if (this.connected) {
|
|
31
|
+
await this.client.end();
|
|
32
|
+
this.connected = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Alias for compatibility
|
|
36
|
+
async disconnect() {
|
|
37
|
+
return this.close();
|
|
38
|
+
}
|
|
39
|
+
async query(sql, params) {
|
|
40
|
+
await this.connect();
|
|
41
|
+
const result = await this.client.query(sql, params);
|
|
42
|
+
return result.rows;
|
|
43
|
+
}
|
|
44
|
+
async queryOne(sql, params) {
|
|
45
|
+
const rows = await this.query(sql, params);
|
|
46
|
+
return rows[0] || null;
|
|
47
|
+
}
|
|
48
|
+
// ============ Health ============
|
|
49
|
+
async health() {
|
|
50
|
+
await this.connect();
|
|
51
|
+
// Get basic stats - check database connectivity
|
|
52
|
+
const dbTime = await this.queryOne('SELECT NOW() as now');
|
|
53
|
+
// Get version from manifest or default
|
|
54
|
+
const version = await this.getVaultVersion();
|
|
55
|
+
// Check HA from environment
|
|
56
|
+
const haEnabled = process.env.HA_ENABLED === 'true';
|
|
57
|
+
const nodeId = process.env.HA_NODE_ID || 'standalone';
|
|
58
|
+
// Get PostgreSQL status
|
|
59
|
+
const pgStatus = await this.getPostgresStatus();
|
|
60
|
+
// Get Redis status
|
|
61
|
+
const redisStatus = await this.getRedisStatus();
|
|
62
|
+
// Get cluster info from Redis if available
|
|
63
|
+
let clusterSize = 1;
|
|
64
|
+
let isLeader = false;
|
|
65
|
+
if (haEnabled && redisStatus.status === 'ok') {
|
|
66
|
+
const clusterInfo = await this.getClusterInfoFromRedis();
|
|
67
|
+
clusterSize = clusterInfo.nodeCount;
|
|
68
|
+
isLeader = clusterInfo.leaderNodeId === nodeId;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
status: 'ok',
|
|
72
|
+
version,
|
|
73
|
+
uptime: process.uptime(),
|
|
74
|
+
timestamp: dbTime?.now.toISOString() || new Date().toISOString(),
|
|
75
|
+
database: pgStatus,
|
|
76
|
+
redis: redisStatus.status !== 'unavailable' ? redisStatus : undefined,
|
|
77
|
+
ha: haEnabled ? {
|
|
78
|
+
enabled: true,
|
|
79
|
+
nodeId,
|
|
80
|
+
isLeader,
|
|
81
|
+
clusterSize,
|
|
82
|
+
} : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async getVaultVersion() {
|
|
86
|
+
// Try to read from manifest file (MANIFEST.json inside release)
|
|
87
|
+
try {
|
|
88
|
+
const fs = await import('node:fs');
|
|
89
|
+
const manifestPaths = [
|
|
90
|
+
'/opt/znvault/current/MANIFEST.json',
|
|
91
|
+
'/opt/znvault/current/manifest.json',
|
|
92
|
+
];
|
|
93
|
+
for (const manifestPath of manifestPaths) {
|
|
94
|
+
if (fs.existsSync(manifestPath)) {
|
|
95
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
96
|
+
return manifest.version || '1.2.9';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Ignore
|
|
102
|
+
}
|
|
103
|
+
return process.env.npm_package_version || '1.2.9';
|
|
104
|
+
}
|
|
105
|
+
async getPostgresStatus() {
|
|
106
|
+
try {
|
|
107
|
+
// Check if this is primary or replica
|
|
108
|
+
const recovery = await this.queryOne('SELECT pg_is_in_recovery() as in_recovery');
|
|
109
|
+
if (recovery?.in_recovery) {
|
|
110
|
+
// This is a replica - check replication lag
|
|
111
|
+
const lag = await this.queryOne("SELECT pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) as lag_bytes");
|
|
112
|
+
return {
|
|
113
|
+
status: 'ok',
|
|
114
|
+
role: 'replica',
|
|
115
|
+
replicationLag: lag ? parseInt(lag.lag_bytes, 10) : 0,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// This is primary - check replication status
|
|
119
|
+
const replicas = await this.query('SELECT client_addr, state FROM pg_stat_replication');
|
|
120
|
+
return {
|
|
121
|
+
status: 'ok',
|
|
122
|
+
role: 'primary',
|
|
123
|
+
replicationLag: replicas.length > 0 ? 0 : undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { status: 'ok' }; // Basic connection works
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async getRedisStatus() {
|
|
131
|
+
const sentinelNodes = process.env.REDIS_SENTINEL_NODES;
|
|
132
|
+
const sentinelMaster = process.env.REDIS_SENTINEL_MASTER || 'znvault-master';
|
|
133
|
+
if (!sentinelNodes) {
|
|
134
|
+
// Check for simple Redis URL
|
|
135
|
+
if (process.env.REDIS_URL) {
|
|
136
|
+
try {
|
|
137
|
+
const { execSync } = await import('node:child_process');
|
|
138
|
+
const result = execSync(`redis-cli -u "${process.env.REDIS_URL}" PING 2>/dev/null`, {
|
|
139
|
+
encoding: 'utf-8',
|
|
140
|
+
timeout: 5000,
|
|
141
|
+
});
|
|
142
|
+
return { status: result.trim() === 'PONG' ? 'ok' : 'error' };
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return { status: 'error' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { status: 'unavailable' };
|
|
149
|
+
}
|
|
150
|
+
// Check Redis Sentinel
|
|
151
|
+
try {
|
|
152
|
+
const { execSync } = await import('node:child_process');
|
|
153
|
+
const nodes = sentinelNodes.split(',');
|
|
154
|
+
let healthyNodes = 0;
|
|
155
|
+
let masterHost = '';
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
const [host, port] = node.split(':');
|
|
158
|
+
try {
|
|
159
|
+
const result = execSync(`redis-cli -h ${host} -p ${port} SENTINEL get-master-addr-by-name ${sentinelMaster} 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 });
|
|
160
|
+
if (result.trim()) {
|
|
161
|
+
healthyNodes++;
|
|
162
|
+
if (!masterHost) {
|
|
163
|
+
const lines = result.trim().split('\n');
|
|
164
|
+
masterHost = lines[0] || '';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Node not reachable
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
status: healthyNodes >= 2 ? 'ok' : (healthyNodes > 0 ? 'degraded' : 'error'),
|
|
174
|
+
sentinelNodes: healthyNodes,
|
|
175
|
+
master: masterHost || undefined,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { status: 'error' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async getClusterInfoFromRedis() {
|
|
183
|
+
const sentinelNodes = process.env.REDIS_SENTINEL_NODES;
|
|
184
|
+
const sentinelMaster = process.env.REDIS_SENTINEL_MASTER || 'znvault-master';
|
|
185
|
+
if (!sentinelNodes) {
|
|
186
|
+
return { nodeCount: 1, leaderNodeId: null };
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const { execSync } = await import('node:child_process');
|
|
190
|
+
const nodes = sentinelNodes.split(',');
|
|
191
|
+
const [host, port] = nodes[0].split(':');
|
|
192
|
+
// Get master info
|
|
193
|
+
const masterResult = execSync(`redis-cli -h ${host} -p ${port} SENTINEL get-master-addr-by-name ${sentinelMaster} 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 });
|
|
194
|
+
const masterHost = masterResult.trim().split('\n')[0];
|
|
195
|
+
// Try to get cluster nodes from Redis
|
|
196
|
+
const masterPort = masterResult.trim().split('\n')[1] || '6379';
|
|
197
|
+
const nodesResult = execSync(`redis-cli -h ${masterHost} -p ${masterPort} HGETALL 'zn-vault:nodes' 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 });
|
|
198
|
+
// Parse node data - HGETALL returns key1, value1, key2, value2, etc.
|
|
199
|
+
const lines = nodesResult.trim().split('\n').filter(l => l);
|
|
200
|
+
const nodeCount = Math.max(Math.floor(lines.length / 2), 1);
|
|
201
|
+
// Get leader
|
|
202
|
+
const leaderResult = execSync(`redis-cli -h ${masterHost} -p ${masterPort} GET 'zn-vault:leader' 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 });
|
|
203
|
+
const leaderNodeId = leaderResult.trim() || null;
|
|
204
|
+
return { nodeCount, leaderNodeId };
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { nodeCount: 3, leaderNodeId: null }; // Default assumption for 3-node cluster
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// ============ Cluster ============
|
|
211
|
+
async clusterStatus() {
|
|
212
|
+
await this.connect();
|
|
213
|
+
// Check HA from environment
|
|
214
|
+
const haEnabled = process.env.HA_ENABLED === 'true';
|
|
215
|
+
const nodeId = process.env.HA_NODE_ID || 'unknown';
|
|
216
|
+
// Get cluster nodes from ha_nodes table if it exists
|
|
217
|
+
let nodes = [];
|
|
218
|
+
try {
|
|
219
|
+
const dbNodes = await this.query(`
|
|
220
|
+
SELECT node_id, advertised_host, advertised_port, is_leader, last_heartbeat, status
|
|
221
|
+
FROM ha_nodes
|
|
222
|
+
ORDER BY node_id
|
|
223
|
+
`);
|
|
224
|
+
nodes = dbNodes.map(n => ({
|
|
225
|
+
nodeId: n.node_id,
|
|
226
|
+
host: n.advertised_host,
|
|
227
|
+
port: n.advertised_port,
|
|
228
|
+
isLeader: n.is_leader,
|
|
229
|
+
isHealthy: n.status === 'healthy',
|
|
230
|
+
lastHeartbeat: n.last_heartbeat?.toISOString() || '',
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Table might not exist in non-HA setups
|
|
235
|
+
}
|
|
236
|
+
// Find leader
|
|
237
|
+
const leader = nodes.find(n => n.isLeader);
|
|
238
|
+
return {
|
|
239
|
+
enabled: haEnabled,
|
|
240
|
+
nodeId,
|
|
241
|
+
isLeader: leader?.nodeId === nodeId,
|
|
242
|
+
leaderNodeId: leader?.nodeId || null,
|
|
243
|
+
nodes,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// ============ Tenants ============
|
|
247
|
+
async listTenants(options) {
|
|
248
|
+
let sql = `
|
|
249
|
+
SELECT t.id, t.name, t.status, t.max_secrets, t.max_kms_keys, t.contact_email,
|
|
250
|
+
t.created_at, t.updated_at
|
|
251
|
+
FROM tenants t
|
|
252
|
+
`;
|
|
253
|
+
const params = [];
|
|
254
|
+
if (options?.status) {
|
|
255
|
+
sql += ' WHERE t.status = $1';
|
|
256
|
+
params.push(options.status);
|
|
257
|
+
}
|
|
258
|
+
sql += ' ORDER BY t.name';
|
|
259
|
+
const rows = await this.query(sql, params);
|
|
260
|
+
const tenants = rows.map(r => ({
|
|
261
|
+
id: r.id,
|
|
262
|
+
name: r.name,
|
|
263
|
+
status: r.status,
|
|
264
|
+
maxSecrets: r.max_secrets ?? undefined,
|
|
265
|
+
maxKmsKeys: r.max_kms_keys ?? undefined,
|
|
266
|
+
contactEmail: r.contact_email ?? undefined,
|
|
267
|
+
createdAt: r.created_at.toISOString(),
|
|
268
|
+
updatedAt: r.updated_at.toISOString(),
|
|
269
|
+
}));
|
|
270
|
+
if (options?.withUsage) {
|
|
271
|
+
for (const tenant of tenants) {
|
|
272
|
+
const usage = await this.getTenantUsage(tenant.id);
|
|
273
|
+
tenant.usage = usage;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return tenants;
|
|
277
|
+
}
|
|
278
|
+
async getTenant(id, withUsage) {
|
|
279
|
+
const row = await this.queryOne('SELECT * FROM tenants WHERE id = $1', [id]);
|
|
280
|
+
if (!row)
|
|
281
|
+
return null;
|
|
282
|
+
const tenant = {
|
|
283
|
+
id: row.id,
|
|
284
|
+
name: row.name,
|
|
285
|
+
status: row.status,
|
|
286
|
+
maxSecrets: row.max_secrets ?? undefined,
|
|
287
|
+
maxKmsKeys: row.max_kms_keys ?? undefined,
|
|
288
|
+
contactEmail: row.contact_email ?? undefined,
|
|
289
|
+
createdAt: row.created_at.toISOString(),
|
|
290
|
+
updatedAt: row.updated_at.toISOString(),
|
|
291
|
+
};
|
|
292
|
+
if (withUsage) {
|
|
293
|
+
tenant.usage = await this.getTenantUsage(id);
|
|
294
|
+
}
|
|
295
|
+
return tenant;
|
|
296
|
+
}
|
|
297
|
+
async getTenantUsage(id) {
|
|
298
|
+
const secrets = await this.queryOne('SELECT COUNT(*) as count FROM secrets WHERE tenant = $1', [id]);
|
|
299
|
+
const kmsKeys = await this.queryOne('SELECT COUNT(*) as count FROM kms_keys WHERE tenant_id = $1', [id]);
|
|
300
|
+
const users = await this.queryOne('SELECT COUNT(*) as count FROM users WHERE tenant_id = $1', [id]);
|
|
301
|
+
const apiKeys = await this.queryOne('SELECT COUNT(*) as count FROM api_keys WHERE tenant_id = $1', [id]);
|
|
302
|
+
return {
|
|
303
|
+
secretsCount: parseInt(secrets?.count || '0', 10),
|
|
304
|
+
kmsKeysCount: parseInt(kmsKeys?.count || '0', 10),
|
|
305
|
+
storageUsedMb: 0, // Would need to calculate
|
|
306
|
+
usersCount: parseInt(users?.count || '0', 10),
|
|
307
|
+
apiKeysCount: parseInt(apiKeys?.count || '0', 10),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// ============ Users ============
|
|
311
|
+
async listUsers(options) {
|
|
312
|
+
let sql = `
|
|
313
|
+
SELECT id, username, email, role, tenant_id, status, totp_enabled,
|
|
314
|
+
failed_attempts, locked_until, last_login, created_at, updated_at
|
|
315
|
+
FROM users
|
|
316
|
+
WHERE 1=1
|
|
317
|
+
`;
|
|
318
|
+
const params = [];
|
|
319
|
+
let paramIndex = 1;
|
|
320
|
+
if (options?.tenantId) {
|
|
321
|
+
sql += ` AND tenant_id = $${paramIndex++}`;
|
|
322
|
+
params.push(options.tenantId);
|
|
323
|
+
}
|
|
324
|
+
if (options?.role) {
|
|
325
|
+
sql += ` AND role = $${paramIndex++}`;
|
|
326
|
+
params.push(options.role);
|
|
327
|
+
}
|
|
328
|
+
if (options?.status) {
|
|
329
|
+
sql += ` AND status = $${paramIndex++}`;
|
|
330
|
+
params.push(options.status);
|
|
331
|
+
}
|
|
332
|
+
sql += ' ORDER BY username';
|
|
333
|
+
const rows = await this.query(sql, params);
|
|
334
|
+
return rows.map(r => ({
|
|
335
|
+
id: r.id,
|
|
336
|
+
username: r.username,
|
|
337
|
+
email: r.email ?? undefined,
|
|
338
|
+
role: r.role,
|
|
339
|
+
tenantId: r.tenant_id ?? undefined,
|
|
340
|
+
status: r.status,
|
|
341
|
+
totpEnabled: r.totp_enabled,
|
|
342
|
+
failedAttempts: r.failed_attempts,
|
|
343
|
+
lockedUntil: r.locked_until?.toISOString(),
|
|
344
|
+
lastLogin: r.last_login?.toISOString(),
|
|
345
|
+
createdAt: r.created_at.toISOString(),
|
|
346
|
+
updatedAt: r.updated_at.toISOString(),
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
async getUser(id) {
|
|
350
|
+
const row = await this.queryOne('SELECT * FROM users WHERE id = $1', [id]);
|
|
351
|
+
if (!row)
|
|
352
|
+
return null;
|
|
353
|
+
return {
|
|
354
|
+
id: row.id,
|
|
355
|
+
username: row.username,
|
|
356
|
+
email: row.email ?? undefined,
|
|
357
|
+
role: row.role,
|
|
358
|
+
tenantId: row.tenant_id ?? undefined,
|
|
359
|
+
status: row.status,
|
|
360
|
+
totpEnabled: row.totp_enabled,
|
|
361
|
+
failedAttempts: row.failed_attempts,
|
|
362
|
+
lockedUntil: row.locked_until?.toISOString(),
|
|
363
|
+
lastLogin: row.last_login?.toISOString(),
|
|
364
|
+
createdAt: row.created_at.toISOString(),
|
|
365
|
+
updatedAt: row.updated_at.toISOString(),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
async getUserByUsername(username) {
|
|
369
|
+
const row = await this.queryOne('SELECT * FROM users WHERE username = $1 OR email = $1', [username]);
|
|
370
|
+
if (!row)
|
|
371
|
+
return null;
|
|
372
|
+
return {
|
|
373
|
+
id: row.id,
|
|
374
|
+
username: row.username,
|
|
375
|
+
email: row.email ?? undefined,
|
|
376
|
+
role: row.role,
|
|
377
|
+
tenantId: row.tenant_id ?? undefined,
|
|
378
|
+
status: row.status,
|
|
379
|
+
totpEnabled: row.totp_enabled,
|
|
380
|
+
failedAttempts: row.failed_attempts,
|
|
381
|
+
lockedUntil: row.locked_until?.toISOString(),
|
|
382
|
+
lastLogin: row.last_login?.toISOString(),
|
|
383
|
+
createdAt: row.created_at.toISOString(),
|
|
384
|
+
updatedAt: row.updated_at.toISOString(),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
// ============ Superadmins ============
|
|
388
|
+
async listSuperadmins() {
|
|
389
|
+
const users = await this.listUsers({ role: 'superadmin' });
|
|
390
|
+
return users.map(u => ({
|
|
391
|
+
id: u.id,
|
|
392
|
+
username: u.username,
|
|
393
|
+
email: u.email,
|
|
394
|
+
status: u.status,
|
|
395
|
+
totpEnabled: u.totpEnabled,
|
|
396
|
+
failedAttempts: u.failedAttempts,
|
|
397
|
+
lockedUntil: u.lockedUntil,
|
|
398
|
+
lastLogin: u.lastLogin,
|
|
399
|
+
createdAt: u.createdAt,
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
// ============ Lockdown ============
|
|
403
|
+
async getLockdownStatus() {
|
|
404
|
+
const row = await this.queryOne('SELECT * FROM lockdown_state ORDER BY updated_at DESC LIMIT 1');
|
|
405
|
+
if (!row) {
|
|
406
|
+
return {
|
|
407
|
+
scope: 'SYSTEM',
|
|
408
|
+
status: 'NORMAL',
|
|
409
|
+
escalationCount: 0,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
scope: row.scope,
|
|
414
|
+
tenantId: row.tenant_id ?? undefined,
|
|
415
|
+
status: row.status,
|
|
416
|
+
reason: row.reason ?? undefined,
|
|
417
|
+
triggeredAt: row.triggered_at?.toISOString(),
|
|
418
|
+
triggeredBy: row.triggered_by ?? undefined,
|
|
419
|
+
escalationCount: row.escalation_count || 0,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
async getLockdownHistory(limit = 50) {
|
|
423
|
+
const rows = await this.query('SELECT * FROM lockdown_history ORDER BY created_at DESC LIMIT $1', [limit]);
|
|
424
|
+
return rows.map(r => ({
|
|
425
|
+
id: r.id,
|
|
426
|
+
previousStatus: r.previous_status,
|
|
427
|
+
newStatus: r.new_status,
|
|
428
|
+
transitionReason: r.transition_reason,
|
|
429
|
+
changedByUserId: r.changed_by_user_id ?? undefined,
|
|
430
|
+
changedBySystem: r.changed_by_system,
|
|
431
|
+
ts: r.created_at.toISOString(),
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
async getThreats(options) {
|
|
435
|
+
let sql = 'SELECT * FROM threat_events WHERE 1=1';
|
|
436
|
+
const params = [];
|
|
437
|
+
let paramIndex = 1;
|
|
438
|
+
if (options?.category) {
|
|
439
|
+
sql += ` AND category = $${paramIndex++}`;
|
|
440
|
+
params.push(options.category);
|
|
441
|
+
}
|
|
442
|
+
if (options?.since) {
|
|
443
|
+
sql += ` AND created_at >= $${paramIndex++}`;
|
|
444
|
+
params.push(new Date(options.since));
|
|
445
|
+
}
|
|
446
|
+
sql += ` ORDER BY created_at DESC LIMIT $${paramIndex}`;
|
|
447
|
+
params.push(options?.limit || 100);
|
|
448
|
+
const rows = await this.query(sql, params);
|
|
449
|
+
return rows.map(r => ({
|
|
450
|
+
id: r.id,
|
|
451
|
+
ts: r.created_at.toISOString(),
|
|
452
|
+
tenantId: r.tenant_id ?? undefined,
|
|
453
|
+
userId: r.user_id ?? undefined,
|
|
454
|
+
ip: r.ip,
|
|
455
|
+
userAgent: r.user_agent ?? undefined,
|
|
456
|
+
category: r.category,
|
|
457
|
+
signal: r.signal,
|
|
458
|
+
suggestedLevel: r.suggested_level,
|
|
459
|
+
endpoint: r.endpoint,
|
|
460
|
+
method: r.method,
|
|
461
|
+
statusCode: r.status_code,
|
|
462
|
+
escalated: r.escalated,
|
|
463
|
+
}));
|
|
464
|
+
}
|
|
465
|
+
// ============ Audit ============
|
|
466
|
+
async listAudit(options) {
|
|
467
|
+
let sql = 'SELECT * FROM audit_log WHERE 1=1';
|
|
468
|
+
const params = [];
|
|
469
|
+
let paramIndex = 1;
|
|
470
|
+
if (options?.user) {
|
|
471
|
+
sql += ` AND (client_cn = $${paramIndex} OR user_id = $${paramIndex})`;
|
|
472
|
+
params.push(options.user);
|
|
473
|
+
paramIndex++;
|
|
474
|
+
}
|
|
475
|
+
if (options?.action) {
|
|
476
|
+
sql += ` AND action = $${paramIndex++}`;
|
|
477
|
+
params.push(options.action);
|
|
478
|
+
}
|
|
479
|
+
if (options?.startDate) {
|
|
480
|
+
sql += ` AND timestamp >= $${paramIndex++}`;
|
|
481
|
+
params.push(new Date(options.startDate));
|
|
482
|
+
}
|
|
483
|
+
if (options?.endDate) {
|
|
484
|
+
sql += ` AND timestamp <= $${paramIndex++}`;
|
|
485
|
+
params.push(new Date(options.endDate));
|
|
486
|
+
}
|
|
487
|
+
sql += ` ORDER BY timestamp DESC LIMIT $${paramIndex}`;
|
|
488
|
+
params.push(options?.limit || 100);
|
|
489
|
+
const rows = await this.query(sql, params);
|
|
490
|
+
return rows.map(r => ({
|
|
491
|
+
id: r.id,
|
|
492
|
+
ts: r.timestamp.toISOString(),
|
|
493
|
+
clientCn: r.client_cn || '',
|
|
494
|
+
action: r.action,
|
|
495
|
+
resource: r.resource_type ? `${r.resource_type}/${r.resource_id || ''}` : '',
|
|
496
|
+
statusCode: r.status_code,
|
|
497
|
+
tenantId: r.tenant_id ?? undefined,
|
|
498
|
+
ip: r.ip_address ?? undefined,
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
async verifyAuditChain() {
|
|
502
|
+
// Get total count
|
|
503
|
+
const countResult = await this.queryOne('SELECT COUNT(*) as count FROM audit_log');
|
|
504
|
+
const total = parseInt(countResult?.count || '0', 10);
|
|
505
|
+
if (total === 0) {
|
|
506
|
+
return {
|
|
507
|
+
valid: true,
|
|
508
|
+
totalEntries: 0,
|
|
509
|
+
verifiedEntries: 0,
|
|
510
|
+
message: 'No audit entries to verify',
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// For now, return a basic verification
|
|
514
|
+
// Full HMAC chain verification would require the secret key
|
|
515
|
+
return {
|
|
516
|
+
valid: true,
|
|
517
|
+
totalEntries: total,
|
|
518
|
+
verifiedEntries: total,
|
|
519
|
+
message: `Verified ${total} audit entries (chain integrity check requires API access)`,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
// ============ Emergency Operations ============
|
|
523
|
+
/**
|
|
524
|
+
* Test database connection
|
|
525
|
+
*/
|
|
526
|
+
async testConnection() {
|
|
527
|
+
try {
|
|
528
|
+
await this.connect();
|
|
529
|
+
const result = await this.queryOne('SELECT NOW() as time, current_database() as db');
|
|
530
|
+
return {
|
|
531
|
+
success: true,
|
|
532
|
+
message: `Connected to database '${result?.db}' at ${result?.time}`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
return {
|
|
537
|
+
success: false,
|
|
538
|
+
message: `Connection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Get user status (for diagnostics)
|
|
544
|
+
*/
|
|
545
|
+
async getUserStatus(username) {
|
|
546
|
+
const user = await this.getUserByUsername(username);
|
|
547
|
+
if (!user) {
|
|
548
|
+
return { found: false };
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
found: true,
|
|
552
|
+
user: {
|
|
553
|
+
id: user.id,
|
|
554
|
+
username: user.username,
|
|
555
|
+
email: user.email ?? null,
|
|
556
|
+
role: user.role,
|
|
557
|
+
status: user.status,
|
|
558
|
+
totpEnabled: user.totpEnabled,
|
|
559
|
+
failedAttempts: user.failedAttempts,
|
|
560
|
+
lockedUntil: user.lockedUntil ?? null,
|
|
561
|
+
lastLogin: user.lastLogin ?? null,
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Reset a user's password directly in the database.
|
|
567
|
+
*/
|
|
568
|
+
async resetPassword(username, newPassword) {
|
|
569
|
+
await this.connect();
|
|
570
|
+
try {
|
|
571
|
+
const passwordHash = bcryptjs.hashSync(newPassword, 12);
|
|
572
|
+
const findResult = await this.queryOne('SELECT id, username FROM users WHERE username = $1 OR email = $1', [username]);
|
|
573
|
+
if (!findResult) {
|
|
574
|
+
return { success: false, message: `User '${username}' not found` };
|
|
575
|
+
}
|
|
576
|
+
await this.client.query(`UPDATE users SET
|
|
577
|
+
password_hash = $1,
|
|
578
|
+
totp_enabled = false,
|
|
579
|
+
totp_secret_cipher = NULL,
|
|
580
|
+
totp_nonce = NULL,
|
|
581
|
+
totp_tag = NULL,
|
|
582
|
+
backup_codes_cipher = NULL,
|
|
583
|
+
backup_codes_nonce = NULL,
|
|
584
|
+
backup_codes_tag = NULL,
|
|
585
|
+
failed_attempts = 0,
|
|
586
|
+
locked_until = NULL,
|
|
587
|
+
status = 'active',
|
|
588
|
+
password_must_change = true,
|
|
589
|
+
updated_at = NOW()
|
|
590
|
+
WHERE id = $2`, [passwordHash, findResult.id]);
|
|
591
|
+
return {
|
|
592
|
+
success: true,
|
|
593
|
+
message: `Password reset for user '${findResult.username}'. TOTP disabled, account unlocked.`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
return {
|
|
598
|
+
success: false,
|
|
599
|
+
message: `Failed to reset password: ${err instanceof Error ? err.message : String(err)}`,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Unlock a locked user account
|
|
605
|
+
*/
|
|
606
|
+
async unlockUser(username) {
|
|
607
|
+
await this.connect();
|
|
608
|
+
try {
|
|
609
|
+
const findResult = await this.queryOne('SELECT id, username, status, failed_attempts, locked_until FROM users WHERE username = $1 OR email = $1', [username]);
|
|
610
|
+
if (!findResult) {
|
|
611
|
+
return { success: false, message: `User '${username}' not found` };
|
|
612
|
+
}
|
|
613
|
+
if (findResult.status === 'active' && findResult.failed_attempts === 0 && !findResult.locked_until) {
|
|
614
|
+
return { success: true, message: `User '${findResult.username}' is already unlocked` };
|
|
615
|
+
}
|
|
616
|
+
await this.client.query(`UPDATE users SET
|
|
617
|
+
status = 'active',
|
|
618
|
+
failed_attempts = 0,
|
|
619
|
+
locked_until = NULL,
|
|
620
|
+
updated_at = NOW()
|
|
621
|
+
WHERE id = $1`, [findResult.id]);
|
|
622
|
+
return {
|
|
623
|
+
success: true,
|
|
624
|
+
message: `User '${findResult.username}' has been unlocked`,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
return {
|
|
629
|
+
success: false,
|
|
630
|
+
message: `Failed to unlock user: ${err instanceof Error ? err.message : String(err)}`,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Disable TOTP for a user
|
|
636
|
+
*/
|
|
637
|
+
async disableTotp(username) {
|
|
638
|
+
await this.connect();
|
|
639
|
+
try {
|
|
640
|
+
const findResult = await this.queryOne('SELECT id, username, totp_enabled FROM users WHERE username = $1 OR email = $1', [username]);
|
|
641
|
+
if (!findResult) {
|
|
642
|
+
return { success: false, message: `User '${username}' not found` };
|
|
643
|
+
}
|
|
644
|
+
if (!findResult.totp_enabled) {
|
|
645
|
+
return { success: true, message: `TOTP is already disabled for '${findResult.username}'` };
|
|
646
|
+
}
|
|
647
|
+
await this.client.query(`UPDATE users SET
|
|
648
|
+
totp_enabled = false,
|
|
649
|
+
totp_secret_cipher = NULL,
|
|
650
|
+
totp_nonce = NULL,
|
|
651
|
+
totp_tag = NULL,
|
|
652
|
+
backup_codes_cipher = NULL,
|
|
653
|
+
backup_codes_nonce = NULL,
|
|
654
|
+
backup_codes_tag = NULL,
|
|
655
|
+
updated_at = NOW()
|
|
656
|
+
WHERE id = $1`, [findResult.id]);
|
|
657
|
+
return {
|
|
658
|
+
success: true,
|
|
659
|
+
message: `TOTP disabled for user '${findResult.username}'`,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
return {
|
|
664
|
+
success: false,
|
|
665
|
+
message: `Failed to disable TOTP: ${err instanceof Error ? err.message : String(err)}`,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// ============ Legacy exports for backward compatibility ============
|
|
671
|
+
/**
|
|
672
|
+
* Legacy EmergencyDBClient class (alias for LocalDBClient)
|
|
673
|
+
* @deprecated Use LocalDBClient instead
|
|
674
|
+
*/
|
|
675
|
+
export class EmergencyDBClient extends LocalDBClient {
|
|
676
|
+
constructor() {
|
|
677
|
+
// For emergency operations, DATABASE_URL must be set
|
|
678
|
+
if (!process.env.DATABASE_URL) {
|
|
679
|
+
throw new Error('DATABASE_URL environment variable is required for emergency operations.\n' +
|
|
680
|
+
'This should only be set when running directly on a vault node.');
|
|
681
|
+
}
|
|
682
|
+
super();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Check if emergency DB access is available
|
|
687
|
+
*/
|
|
688
|
+
export function isEmergencyDbAvailable() {
|
|
689
|
+
return !!process.env.DATABASE_URL;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Check if local mode is available (more comprehensive check)
|
|
693
|
+
*/
|
|
694
|
+
export function isLocalDbAvailable() {
|
|
695
|
+
const config = getLocalConfig();
|
|
696
|
+
return config !== null;
|
|
697
|
+
}
|
|
698
|
+
//# sourceMappingURL=db.js.map
|