@terreno/api 0.19.0 → 0.20.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/CHANGELOG.md +25 -0
- package/dist/configuration.test.js +289 -10
- package/dist/configurationApp.d.ts +72 -5
- package/dist/configurationApp.js +168 -48
- package/dist/configurationPlugin.d.ts +64 -7
- package/dist/configurationPlugin.js +161 -39
- package/dist/configurationPlugin.test.js +238 -1
- package/dist/secretProviders.d.ts +79 -2
- package/dist/secretProviders.js +177 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/configuration.test.ts +171 -7
- package/src/configurationApp.ts +213 -30
- package/src/configurationPlugin.test.ts +174 -2
- package/src/configurationPlugin.ts +157 -28
- package/src/secretProviders.test.ts +186 -0
- package/src/secretProviders.ts +145 -5
|
@@ -93,16 +93,15 @@ const ScalarConfig = (mongoose.models.ScalarConfig ||
|
|
|
93
93
|
|
|
94
94
|
const buildApp = (
|
|
95
95
|
configModel: mongoose.Model<any>,
|
|
96
|
-
options?:
|
|
96
|
+
options?: Partial<Omit<ConstructorParameters<typeof ConfigurationApp>[0], "model">>
|
|
97
97
|
): express.Application => {
|
|
98
98
|
const app = getBaseServer();
|
|
99
99
|
setupAuth(app, UserModel as any);
|
|
100
100
|
addAuthRoutes(app, UserModel as any);
|
|
101
101
|
|
|
102
102
|
const configApp = new ConfigurationApp({
|
|
103
|
-
basePath: options?.basePath,
|
|
104
|
-
fieldOverrides: options?.fieldOverrides,
|
|
105
103
|
model: configModel,
|
|
104
|
+
...options,
|
|
106
105
|
});
|
|
107
106
|
configApp.register(app);
|
|
108
107
|
|
|
@@ -168,6 +167,31 @@ describe("configurationPlugin", () => {
|
|
|
168
167
|
expect(updated.general.appName).toBe("Changed");
|
|
169
168
|
expect(updated.general.maintenanceMode).toBe(true);
|
|
170
169
|
});
|
|
170
|
+
|
|
171
|
+
it("preserves sibling subdoc fields on a partial nested patch", async () => {
|
|
172
|
+
await TestConfig.updateConfig({general: {appName: "Initial", maintenanceMode: true}});
|
|
173
|
+
// Patch only one field within the nested subdoc.
|
|
174
|
+
const updated = await TestConfig.updateConfig({general: {appName: "Renamed"}});
|
|
175
|
+
expect(updated.general.appName).toBe("Renamed");
|
|
176
|
+
// Sibling must be preserved (not clobbered back to the default).
|
|
177
|
+
expect(updated.general.maintenanceMode).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("tolerates legacy/out-of-schema fields already persisted", async () => {
|
|
181
|
+
// Insert a document containing a field that is not in the (strict: throw) schema.
|
|
182
|
+
await TestConfig.getConfig();
|
|
183
|
+
await mongoose.connection.db
|
|
184
|
+
?.collection("testconfigs")
|
|
185
|
+
.updateOne({}, {$set: {legacyField: "stale"}});
|
|
186
|
+
|
|
187
|
+
// A full doc.save() would throw under strict: "throw"; $set must not.
|
|
188
|
+
const updated = await TestConfig.updateConfig({general: {appName: "Survives"}});
|
|
189
|
+
expect(updated.general.appName).toBe("Survives");
|
|
190
|
+
|
|
191
|
+
// The legacy field is left untouched on the document.
|
|
192
|
+
const raw = await mongoose.connection.db?.collection("testconfigs").findOne({});
|
|
193
|
+
expect(raw?.legacyField).toBe("stale");
|
|
194
|
+
});
|
|
171
195
|
});
|
|
172
196
|
|
|
173
197
|
describe("singleton enforcement", () => {
|
|
@@ -327,12 +351,18 @@ describe("ConfigurationApp routes", () => {
|
|
|
327
351
|
expect(res.body.data.general.appName).toBe("New Name");
|
|
328
352
|
});
|
|
329
353
|
|
|
330
|
-
it("
|
|
354
|
+
it("never persists secret field values supplied in the body", async () => {
|
|
331
355
|
const res = await adminAgent
|
|
332
356
|
.patch("/configuration")
|
|
333
|
-
.send({integrations: {apiKey: "new-secret"}})
|
|
357
|
+
.send({integrations: {apiKey: "new-secret", webhookUrl: "https://changed.example.com"}})
|
|
334
358
|
.expect(200);
|
|
335
|
-
|
|
359
|
+
// Secret stays empty (stripped); non-secret sibling is updated.
|
|
360
|
+
expect(res.body.data.integrations.apiKey).toBe("");
|
|
361
|
+
expect(res.body.data.integrations.webhookUrl).toBe("https://changed.example.com");
|
|
362
|
+
|
|
363
|
+
// Confirm the raw stored document holds no secret value.
|
|
364
|
+
const stored = await (TestConfig as any).getConfig();
|
|
365
|
+
expect(stored.integrations.apiKey).toBe("");
|
|
336
366
|
});
|
|
337
367
|
|
|
338
368
|
it("returns 403 for non-admin", async () => {
|
|
@@ -344,11 +374,29 @@ describe("ConfigurationApp routes", () => {
|
|
|
344
374
|
});
|
|
345
375
|
|
|
346
376
|
describe("POST /configuration/list-secrets", () => {
|
|
347
|
-
it("returns discovered secret fields", async () => {
|
|
377
|
+
it("returns discovered secret fields with resolvable status", async () => {
|
|
348
378
|
const res = await adminAgent.post("/configuration/list-secrets").expect(200);
|
|
349
379
|
expect(res.body.secretFields).toHaveLength(1);
|
|
350
380
|
expect(res.body.secretFields[0].path).toBe("integrations.apiKey");
|
|
351
381
|
expect(res.body.secretFields[0].secretName).toBe("external-api-key");
|
|
382
|
+
// No provider configured -> not resolvable, and no value is ever returned.
|
|
383
|
+
expect(res.body.secretFields[0].resolvable).toBe(false);
|
|
384
|
+
expect(res.body.secretFields[0].isConfigured).toBe(false);
|
|
385
|
+
expect(JSON.stringify(res.body)).not.toContain("super-secret");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("does not mutate the stored config document's secret fields", async () => {
|
|
389
|
+
await (TestConfig as any).getConfig();
|
|
390
|
+
await adminAgent.post("/configuration/list-secrets").expect(200);
|
|
391
|
+
const stored = await (TestConfig as any).getConfig();
|
|
392
|
+
// list-secrets must never write resolved values into the document.
|
|
393
|
+
expect(stored.integrations.apiKey).toBe("");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("is also exposed as validate-secrets", async () => {
|
|
397
|
+
const res = await adminAgent.post("/configuration/validate-secrets").expect(200);
|
|
398
|
+
expect(res.body.secretFields).toHaveLength(1);
|
|
399
|
+
expect(res.body.secretFields[0].path).toBe("integrations.apiKey");
|
|
352
400
|
});
|
|
353
401
|
|
|
354
402
|
it("returns 403 for non-admin", async () => {
|
|
@@ -397,3 +445,119 @@ describe("ConfigurationApp with field overrides", () => {
|
|
|
397
445
|
expect(intSection.fields.webhookUrl.widget).toBe("url");
|
|
398
446
|
});
|
|
399
447
|
});
|
|
448
|
+
|
|
449
|
+
describe("ConfigurationApp with custom permissions", () => {
|
|
450
|
+
let app: express.Application;
|
|
451
|
+
let adminAgent: TestAgent;
|
|
452
|
+
let notAdminAgent: TestAgent;
|
|
453
|
+
|
|
454
|
+
// Terreno-style permission: any authenticated user (not just admins).
|
|
455
|
+
const isAuthenticated = (_method: any, user?: any): boolean => Boolean(user?.id);
|
|
456
|
+
|
|
457
|
+
beforeEach(async () => {
|
|
458
|
+
await setupDb();
|
|
459
|
+
await mongoose.connection.db?.collection("testconfigs").deleteMany({});
|
|
460
|
+
app = buildApp(TestConfig, {
|
|
461
|
+
permissions: {
|
|
462
|
+
read: [isAuthenticated],
|
|
463
|
+
// update intentionally left default (admin-only)
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
adminAgent = await authAsUser(app, "admin");
|
|
467
|
+
notAdminAgent = await authAsUser(app, "notAdmin");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("allows non-admin reads when a custom read permission is supplied", async () => {
|
|
471
|
+
const res = await notAdminAgent.get("/configuration").expect(200);
|
|
472
|
+
expect(res.body.data.general.appName).toBe("Test App");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("still rejects unauthenticated reads", async () => {
|
|
476
|
+
await supertest(app).get("/configuration").expect(401);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("keeps update admin-only by default", async () => {
|
|
480
|
+
await notAdminAgent
|
|
481
|
+
.patch("/configuration")
|
|
482
|
+
.send({general: {appName: "Nope"}})
|
|
483
|
+
.expect(403);
|
|
484
|
+
await adminAgent
|
|
485
|
+
.patch("/configuration")
|
|
486
|
+
.send({general: {appName: "Yes"}})
|
|
487
|
+
.expect(200);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("ConfigurationApp with update hooks", () => {
|
|
492
|
+
let app: express.Application;
|
|
493
|
+
let adminAgent: TestAgent;
|
|
494
|
+
let preUpdateCalls: Array<Record<string, unknown>>;
|
|
495
|
+
let postUpdateCalls: Array<{config: Record<string, unknown>; prev: Record<string, unknown>}>;
|
|
496
|
+
|
|
497
|
+
beforeEach(async () => {
|
|
498
|
+
await setupDb();
|
|
499
|
+
await mongoose.connection.db?.collection("testconfigs").deleteMany({});
|
|
500
|
+
preUpdateCalls = [];
|
|
501
|
+
postUpdateCalls = [];
|
|
502
|
+
app = buildApp(TestConfig, {
|
|
503
|
+
postUpdate: async (config, prevValue) => {
|
|
504
|
+
postUpdateCalls.push({config, prev: prevValue});
|
|
505
|
+
},
|
|
506
|
+
preUpdate: async (body) => {
|
|
507
|
+
preUpdateCalls.push(body);
|
|
508
|
+
// Normalize: force maintenanceMode on.
|
|
509
|
+
const general = (body.general as Record<string, unknown>) ?? {};
|
|
510
|
+
return {...body, general: {...general, maintenanceMode: true}};
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
adminAgent = await authAsUser(app, "admin");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("runs preUpdate to transform the body before applying", async () => {
|
|
517
|
+
const res = await adminAgent
|
|
518
|
+
.patch("/configuration")
|
|
519
|
+
.send({general: {appName: "Hooked"}})
|
|
520
|
+
.expect(200);
|
|
521
|
+
expect(res.body.data.general.appName).toBe("Hooked");
|
|
522
|
+
expect(res.body.data.general.maintenanceMode).toBe(true);
|
|
523
|
+
expect(preUpdateCalls).toHaveLength(1);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("does not persist secret values even if preUpdate re-introduces them", async () => {
|
|
527
|
+
// Build an app whose preUpdate maliciously/accidentally re-adds a secret path.
|
|
528
|
+
const leakyApp = buildApp(TestConfig, {
|
|
529
|
+
preUpdate: (body) => ({
|
|
530
|
+
...body,
|
|
531
|
+
integrations: {...(body.integrations as Record<string, unknown>), apiKey: "leaked-secret"},
|
|
532
|
+
}),
|
|
533
|
+
});
|
|
534
|
+
const agent = await authAsUser(leakyApp, "admin");
|
|
535
|
+
|
|
536
|
+
const res = await agent
|
|
537
|
+
.patch("/configuration")
|
|
538
|
+
.send({general: {appName: "Safe"}})
|
|
539
|
+
.expect(200);
|
|
540
|
+
expect(res.body.data.general.appName).toBe("Safe");
|
|
541
|
+
expect(res.body.data.integrations.apiKey).toBe("");
|
|
542
|
+
|
|
543
|
+
const stored = await (TestConfig as any).getConfig();
|
|
544
|
+
expect(stored.integrations.apiKey).toBe("");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("runs postUpdate with redacted config and previous value", async () => {
|
|
548
|
+
// Seed a secret so we can confirm it is redacted in hook payloads.
|
|
549
|
+
await (TestConfig as any).updateConfig({integrations: {apiKey: "should-not-leak"}});
|
|
550
|
+
await adminAgent
|
|
551
|
+
.patch("/configuration")
|
|
552
|
+
.send({general: {appName: "Audited"}})
|
|
553
|
+
.expect(200);
|
|
554
|
+
|
|
555
|
+
expect(postUpdateCalls).toHaveLength(1);
|
|
556
|
+
const {config, prev} = postUpdateCalls[0];
|
|
557
|
+
expect((config.general as any).appName).toBe("Audited");
|
|
558
|
+
// Secret values must be redacted in both payloads.
|
|
559
|
+
expect((config.integrations as any).apiKey).toBe("********");
|
|
560
|
+
expect((prev.integrations as any).apiKey).toBe("********");
|
|
561
|
+
expect(JSON.stringify(postUpdateCalls)).not.toContain("should-not-leak");
|
|
562
|
+
});
|
|
563
|
+
});
|
package/src/configurationApp.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type express from "express";
|
|
2
2
|
import type {Model, Schema} from "mongoose";
|
|
3
3
|
|
|
4
|
-
import {asyncHandler} from "./api";
|
|
4
|
+
import {asyncHandler, type RESTMethod} from "./api";
|
|
5
5
|
import {authenticateMiddleware} from "./auth";
|
|
6
6
|
import type {SecretFieldMeta} from "./configurationPlugin";
|
|
7
7
|
import {APIError} from "./errors";
|
|
8
8
|
import {logger} from "./logger";
|
|
9
|
+
import {checkPermissions, type PermissionMethod} from "./permissions";
|
|
9
10
|
import {getOpenApiSpecForModel} from "./populate";
|
|
10
11
|
import type {TerrenoPlugin} from "./terrenoPlugin";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Middleware that requires the user to be an admin.
|
|
14
|
+
* Middleware that requires the user to be an admin. Used as the default guard
|
|
15
|
+
* for every configuration route when no custom `permissions` are supplied.
|
|
14
16
|
*/
|
|
15
17
|
const requireAdmin = (
|
|
16
18
|
req: express.Request,
|
|
@@ -24,6 +26,29 @@ const requireAdmin = (
|
|
|
24
26
|
next();
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Builds an Express middleware that AND-combines terreno permission functions
|
|
31
|
+
* (the same {@link PermissionMethod} contract used by `modelRouter`). The
|
|
32
|
+
* configuration singleton has no per-object ownership, so the loaded config
|
|
33
|
+
* document is passed as the permission object.
|
|
34
|
+
*/
|
|
35
|
+
const buildPermissionMiddleware = (
|
|
36
|
+
perms: PermissionMethod<unknown>[],
|
|
37
|
+
method: RESTMethod,
|
|
38
|
+
loadObj?: () => Promise<unknown>
|
|
39
|
+
): express.RequestHandler =>
|
|
40
|
+
asyncHandler(async (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
41
|
+
const obj = loadObj ? await loadObj() : undefined;
|
|
42
|
+
const allowed = await checkPermissions(method, perms, req.user, obj);
|
|
43
|
+
if (!allowed) {
|
|
44
|
+
throw new APIError({
|
|
45
|
+
status: 403,
|
|
46
|
+
title: "Access to configuration denied",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
next();
|
|
50
|
+
});
|
|
51
|
+
|
|
27
52
|
/**
|
|
28
53
|
* Metadata for a single configuration field, sent to the frontend.
|
|
29
54
|
*/
|
|
@@ -54,6 +79,54 @@ export interface ConfigurationMetaResponse {
|
|
|
54
79
|
sections: ConfigSectionMeta[];
|
|
55
80
|
}
|
|
56
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Per-route permission overrides for ConfigurationApp. Each value is an array of
|
|
84
|
+
* terreno permission functions ({@link PermissionMethod}), AND-combined like
|
|
85
|
+
* `modelRouter` permissions. When a route is omitted, the default admin-only
|
|
86
|
+
* guard applies.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* permissions: {
|
|
91
|
+
* read: [IsStaff],
|
|
92
|
+
* update: [IsSuperUser],
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export interface ConfigurationPermissions {
|
|
97
|
+
/** Guards `GET {basePath}` (current values). */
|
|
98
|
+
read?: PermissionMethod<unknown>[];
|
|
99
|
+
/** Guards `PATCH {basePath}` (update values). */
|
|
100
|
+
update?: PermissionMethod<unknown>[];
|
|
101
|
+
/** Guards `GET {basePath}/meta` (schema metadata). */
|
|
102
|
+
meta?: PermissionMethod<unknown>[];
|
|
103
|
+
/** Guards `POST {basePath}/list-secrets` and `/validate-secrets`. */
|
|
104
|
+
listSecrets?: PermissionMethod<unknown>[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Hook invoked before a configuration update is applied. Receives the incoming
|
|
109
|
+
* (already system-field- and secret-field-stripped) body and the request, and
|
|
110
|
+
* returns the body to apply. Use it to validate or normalize input. Throw an
|
|
111
|
+
* {@link APIError} to reject the update.
|
|
112
|
+
*/
|
|
113
|
+
export type ConfigurationPreUpdateHook = (
|
|
114
|
+
body: Record<string, unknown>,
|
|
115
|
+
req: express.Request
|
|
116
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hook invoked after a configuration update is applied. Receives the updated
|
|
120
|
+
* configuration and the previous value (both with secret values redacted) plus
|
|
121
|
+
* the request, enabling audit logging of who changed what. Secret values are
|
|
122
|
+
* never included.
|
|
123
|
+
*/
|
|
124
|
+
export type ConfigurationPostUpdateHook = (
|
|
125
|
+
config: Record<string, unknown>,
|
|
126
|
+
prevValue: Record<string, unknown>,
|
|
127
|
+
req: express.Request
|
|
128
|
+
) => void | Promise<void>;
|
|
129
|
+
|
|
57
130
|
/**
|
|
58
131
|
* Options for ConfigurationApp.
|
|
59
132
|
*/
|
|
@@ -65,6 +138,16 @@ export interface ConfigurationAppOptions {
|
|
|
65
138
|
basePath?: string;
|
|
66
139
|
/** Per-field widget overrides (e.g., {"ai.systemPrompt": "markdown"}). */
|
|
67
140
|
fieldOverrides?: Record<string, {widget?: string}>;
|
|
141
|
+
/**
|
|
142
|
+
* Per-route permission overrides. Defaults to admin-only for every route when
|
|
143
|
+
* omitted. Supply terreno permission functions (e.g. `[IsStaff]`) to expose
|
|
144
|
+
* configuration to a consumer's own permission system.
|
|
145
|
+
*/
|
|
146
|
+
permissions?: ConfigurationPermissions;
|
|
147
|
+
/** Hook run before an update is applied (validation/normalization). */
|
|
148
|
+
preUpdate?: ConfigurationPreUpdateHook;
|
|
149
|
+
/** Hook run after an update is applied (audit logging). */
|
|
150
|
+
postUpdate?: ConfigurationPostUpdateHook;
|
|
68
151
|
}
|
|
69
152
|
|
|
70
153
|
/**
|
|
@@ -152,6 +235,41 @@ const redactSecrets = (
|
|
|
152
235
|
return redacted;
|
|
153
236
|
};
|
|
154
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Removes secret field values from an incoming update body so a secret value can
|
|
240
|
+
* never be written to the configuration document through the update path.
|
|
241
|
+
* Copies nodes along each secret path to avoid mutating the caller's object.
|
|
242
|
+
*/
|
|
243
|
+
const stripSecretFields = (
|
|
244
|
+
obj: Record<string, unknown>,
|
|
245
|
+
secretFields: SecretFieldMeta[]
|
|
246
|
+
): Record<string, unknown> => {
|
|
247
|
+
const stripped: Record<string, unknown> = {...obj};
|
|
248
|
+
for (const field of secretFields) {
|
|
249
|
+
const parts = field.path.split(".");
|
|
250
|
+
let current: Record<string, unknown> | null = stripped;
|
|
251
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
252
|
+
if (!current) {
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
const part = parts[i];
|
|
256
|
+
const nested = current[part];
|
|
257
|
+
if (nested != null && typeof nested === "object") {
|
|
258
|
+
const copy = {...(nested as Record<string, unknown>)};
|
|
259
|
+
current[part] = copy;
|
|
260
|
+
current = copy;
|
|
261
|
+
} else {
|
|
262
|
+
current = null;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (current != null) {
|
|
267
|
+
delete current[parts[parts.length - 1]];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return stripped;
|
|
271
|
+
};
|
|
272
|
+
|
|
155
273
|
/**
|
|
156
274
|
* Converts a camelCase or PascalCase string into a display-friendly title.
|
|
157
275
|
*/
|
|
@@ -167,11 +285,21 @@ const toDisplayName = (name: string): string => {
|
|
|
167
285
|
*
|
|
168
286
|
* Inspects the Mongoose configuration model to auto-generate:
|
|
169
287
|
* - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
|
|
170
|
-
* - `GET {basePath}` — Current configuration values
|
|
171
|
-
* - `PATCH {basePath}` — Update configuration values
|
|
172
|
-
* - `POST {basePath}/
|
|
288
|
+
* - `GET {basePath}` — Current configuration values (secret values redacted)
|
|
289
|
+
* - `PATCH {basePath}` — Update configuration values (secret fields stripped; never written)
|
|
290
|
+
* - `POST {basePath}/list-secrets` (alias `POST {basePath}/validate-secrets`) —
|
|
291
|
+
* Read-only status of each secret field (whether the provider can resolve it).
|
|
292
|
+
* This endpoint never resolves values into the document and returns no secret values.
|
|
293
|
+
*
|
|
294
|
+
* By default all endpoints require `Permissions.IsAdmin`. Supply `permissions`
|
|
295
|
+
* to gate routes with a consumer's own permission functions, and `preUpdate`/
|
|
296
|
+
* `postUpdate` hooks to validate and audit-log changes. This makes
|
|
297
|
+
* `ConfigurationApp` suitable as a single, consumer-owned configuration surface
|
|
298
|
+
* that can replace a bespoke config router.
|
|
173
299
|
*
|
|
174
|
-
*
|
|
300
|
+
* Secret values never touch the database, logs, audit payloads, or API
|
|
301
|
+
* responses: secret fields are stripped from incoming updates and redacted on
|
|
302
|
+
* every read.
|
|
175
303
|
*
|
|
176
304
|
* Nested subschemas in the model become separate sections in the metadata,
|
|
177
305
|
* making them renderable as cards/accordions in the admin UI.
|
|
@@ -193,7 +321,10 @@ const toDisplayName = (name: string): string => {
|
|
|
193
321
|
* const AppConfig = mongoose.model("AppConfig", configSchema);
|
|
194
322
|
*
|
|
195
323
|
* new TerrenoApp({ userModel: User })
|
|
196
|
-
* .configure(AppConfig
|
|
324
|
+
* .configure(AppConfig, {
|
|
325
|
+
* permissions: {read: [IsStaff], update: [IsSuperUser]},
|
|
326
|
+
* postUpdate: (config, prevValue, req) => auditLog(req.user, prevValue, config),
|
|
327
|
+
* })
|
|
197
328
|
* .start();
|
|
198
329
|
* ```
|
|
199
330
|
*/
|
|
@@ -204,6 +335,21 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
204
335
|
this.options = options;
|
|
205
336
|
}
|
|
206
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Resolves the guard middleware for a route: the consumer's terreno permission
|
|
340
|
+
* functions when supplied, otherwise the default admin-only guard.
|
|
341
|
+
*/
|
|
342
|
+
private guardFor(
|
|
343
|
+
route: keyof ConfigurationPermissions,
|
|
344
|
+
method: RESTMethod
|
|
345
|
+
): express.RequestHandler {
|
|
346
|
+
const perms = this.options.permissions?.[route];
|
|
347
|
+
if (perms && perms.length > 0) {
|
|
348
|
+
return buildPermissionMiddleware(perms, method);
|
|
349
|
+
}
|
|
350
|
+
return requireAdmin;
|
|
351
|
+
}
|
|
352
|
+
|
|
207
353
|
register(app: express.Application): void {
|
|
208
354
|
const basePath = this.options.basePath ?? "/configuration";
|
|
209
355
|
const ConfigModel = this.options.model;
|
|
@@ -216,7 +362,7 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
216
362
|
app.get(
|
|
217
363
|
`${basePath}/meta`,
|
|
218
364
|
authenticateMiddleware(),
|
|
219
|
-
|
|
365
|
+
this.guardFor("meta", "read"),
|
|
220
366
|
(_req: express.Request, res: express.Response) => {
|
|
221
367
|
return res.json(meta);
|
|
222
368
|
}
|
|
@@ -239,7 +385,7 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
239
385
|
app.get(
|
|
240
386
|
`${basePath}`,
|
|
241
387
|
authenticateMiddleware(),
|
|
242
|
-
|
|
388
|
+
this.guardFor("read", "read"),
|
|
243
389
|
asyncHandler(async (_req: express.Request, res: express.Response) => {
|
|
244
390
|
const config = await ConfigStatics.getConfig();
|
|
245
391
|
const data = redactSecrets(config.toJSON(), secretFields);
|
|
@@ -247,44 +393,81 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
247
393
|
})
|
|
248
394
|
);
|
|
249
395
|
|
|
250
|
-
// PATCH /configuration — update values (secrets redacted in response)
|
|
396
|
+
// PATCH /configuration — update values (secret fields stripped; secrets redacted in response)
|
|
251
397
|
app.patch(
|
|
252
398
|
`${basePath}`,
|
|
253
399
|
authenticateMiddleware(),
|
|
254
|
-
|
|
400
|
+
this.guardFor("update", "update"),
|
|
255
401
|
asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
256
|
-
// Strip internal system fields that should never be updated via the API
|
|
257
|
-
const {_singleton: _s, _id: _i, __v: _v, ...
|
|
402
|
+
// Strip internal system fields that should never be updated via the API.
|
|
403
|
+
const {_singleton: _s, _id: _i, __v: _v, ...rest} = req.body;
|
|
404
|
+
// Strip secret fields so a secret value can never be persisted via update.
|
|
405
|
+
let safeBody = stripSecretFields(rest, secretFields);
|
|
406
|
+
|
|
407
|
+
// Allow consumers to validate/normalize before applying.
|
|
408
|
+
if (this.options.preUpdate) {
|
|
409
|
+
safeBody = await this.options.preUpdate(safeBody, req);
|
|
410
|
+
// Re-strip after the hook: preUpdate receives the raw request and could
|
|
411
|
+
// otherwise (re)introduce secret paths. Secrets must never persist here.
|
|
412
|
+
safeBody = stripSecretFields(safeBody, secretFields);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Capture the previous (redacted) value for audit hooks.
|
|
416
|
+
let prevValue: Record<string, unknown> = {};
|
|
417
|
+
if (this.options.postUpdate) {
|
|
418
|
+
const before = await ConfigStatics.getConfig();
|
|
419
|
+
prevValue = redactSecrets(before.toJSON(), secretFields);
|
|
420
|
+
}
|
|
421
|
+
|
|
258
422
|
const config = await ConfigStatics.updateConfig(safeBody);
|
|
259
423
|
logger.info(`Configuration updated by ${req.user?.email ?? "unknown"}`);
|
|
260
424
|
const data = redactSecrets(config.toJSON(), secretFields);
|
|
425
|
+
|
|
426
|
+
if (this.options.postUpdate) {
|
|
427
|
+
await this.options.postUpdate(data, prevValue, req);
|
|
428
|
+
}
|
|
429
|
+
|
|
261
430
|
return res.json({data});
|
|
262
431
|
})
|
|
263
432
|
);
|
|
264
433
|
|
|
265
|
-
// POST /configuration/list-secrets —
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
434
|
+
// POST /configuration/list-secrets — read-only status of each secret field.
|
|
435
|
+
// Never resolves values into the document and never returns secret values.
|
|
436
|
+
const validateSecretsHandler = asyncHandler(
|
|
437
|
+
async (_req: express.Request, res: express.Response) => {
|
|
438
|
+
// In-memory resolution only — used to report whether each secret is
|
|
439
|
+
// configured/resolvable. Values are never persisted or returned.
|
|
271
440
|
const resolved: Map<string, string> = await ConfigStatics.resolveSecrets();
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
441
|
+
const fields = secretFields.map((s) => ({
|
|
442
|
+
isConfigured: resolved.has(s.path),
|
|
443
|
+
path: s.path,
|
|
444
|
+
resolvable: resolved.has(s.path),
|
|
445
|
+
secretName: s.secretName,
|
|
446
|
+
version: s.version,
|
|
447
|
+
}));
|
|
448
|
+
logger.info(`Validated ${resolved.size}/${secretFields.length} secrets (read-only)`);
|
|
280
449
|
|
|
281
450
|
return res.json({
|
|
282
|
-
message:
|
|
451
|
+
message: `${resolved.size}/${secretFields.length} secrets resolvable.`,
|
|
283
452
|
resolved: resolved.size,
|
|
284
|
-
secretFields:
|
|
453
|
+
secretFields: fields,
|
|
285
454
|
total: secretFields.length,
|
|
286
455
|
});
|
|
287
|
-
}
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
app.post(
|
|
460
|
+
`${basePath}/list-secrets`,
|
|
461
|
+
authenticateMiddleware(),
|
|
462
|
+
this.guardFor("listSecrets", "read"),
|
|
463
|
+
validateSecretsHandler
|
|
464
|
+
);
|
|
465
|
+
// Accurate alias for the read-only validation semantics.
|
|
466
|
+
app.post(
|
|
467
|
+
`${basePath}/validate-secrets`,
|
|
468
|
+
authenticateMiddleware(),
|
|
469
|
+
this.guardFor("listSecrets", "read"),
|
|
470
|
+
validateSecretsHandler
|
|
288
471
|
);
|
|
289
472
|
|
|
290
473
|
logger.info(`Configuration routes mounted at ${basePath}`);
|