@terreno/api 0.19.0 → 0.20.1
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/auth.js +2 -2
- 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 +178 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/auth.ts +2 -2
- 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 +147 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0
|
|
4
|
+
|
|
5
|
+
### Changed (breaking)
|
|
6
|
+
|
|
7
|
+
- **`ConfigurationApp` `POST {basePath}/list-secrets` no longer persists secret values.** Previously it resolved secrets from the provider and wrote the resolved plaintext values into the configuration document. It is now a **read-only validation/status** endpoint: it reports, per secret field, only non-sensitive metadata (`path`, `secretName`, `version`, and a boolean `resolvable`/`isConfigured`) and never writes to the document or returns secret values. A `POST {basePath}/validate-secrets` alias with the same behavior is also registered.
|
|
8
|
+
- **`ConfigurationApp` `PATCH {basePath}` strips `secret: true` fields** from the incoming body, so a secret value can never be written through the update path. Secret fields are read-only via this surface.
|
|
9
|
+
- **`configurationPlugin` no longer adds the `_singleton` unique index by default.** It is now opt-in via `enforceSingletonIndex: true` so it does not double-enforce or conflict with consumers that already guarantee a single non-deleted document via the pre-save guard or their own soft-delete plugin/indexes.
|
|
10
|
+
- **`configurationPlugin` singleton semantics are now soft-delete aware.** `getConfig`, `updateConfig`, and the pre-save guard operate on `{deleted: false}` when the schema has a `deleted` path (e.g. via `isDeletedPlugin`). A soft-deleted document no longer blocks creating a new singleton; hard deletes (`deleteOne`/`deleteMany`/`findOneAndDelete`) remain blocked.
|
|
11
|
+
- **`configurationPlugin.updateConfig` now applies updates via `findOneAndUpdate({$set})` with dotted paths** instead of `Object.assign` + `doc.save()`. This preserves sibling fields inside nested subdocuments on partial patches and tolerates legacy/out-of-schema fields already persisted under `strict: "throw"`.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`SecretProvider.getSecret(secretName, version?)`** — optional `version` parameter. `GcpSecretProvider` resolves `projects/{projectId}/secrets/{name}/versions/{version}` (default `latest`, full resource paths still honored); `EnvSecretProvider` ignores it. Secret fields can declare a `secretVersion` schema option, surfaced on `SecretFieldMeta.version` and passed through `resolveSecrets`.
|
|
16
|
+
- **`CompositeSecretProvider`** — composes an ordered list of providers and returns the first non-null result; a failing provider is warn-logged (secret name only) and resolution falls through to the next.
|
|
17
|
+
- **`CachingSecretProvider`** — wraps any provider with an in-memory TTL cache keyed by `secretName@version`, with `clear()` / `clearKey()` for rotation and tests. Caches `null` results. Never logs values.
|
|
18
|
+
- **`ConfigurationApp` pluggable permissions** — `permissions: {read?, update?, meta?, listSecrets?}` accepts terreno permission functions (e.g. `[IsStaff]`), AND-combined like `modelRouter`. Defaults to admin-only for every route.
|
|
19
|
+
- **`ConfigurationApp` lifecycle hooks** — `preUpdate(body, req)` (validate/normalize) and `postUpdate(config, prevValue, req)` (audit logging). Both payloads have secret values redacted.
|
|
20
|
+
- **`flattenToDotPaths`** — exported helper used by `updateConfig`.
|
|
21
|
+
|
|
22
|
+
### Migration
|
|
23
|
+
|
|
24
|
+
- If you relied on `list-secrets` to populate secret values into the configuration document, stop. Resolve secrets on-demand at runtime via `Model.resolveSecrets(provider)` (returns an in-memory `Map`) and read them from memory; never persist them.
|
|
25
|
+
- If you depended on the `_singleton` unique index, pass `configurationPlugin(schema, {enforceSingletonIndex: true})`.
|
|
26
|
+
- For GCP-with-env-fallback and caching, compose `new CachingSecretProvider(new CompositeSecretProvider([gcp, env]), {ttlMs})`.
|
|
27
|
+
|
|
3
28
|
## 0.16.0
|
|
4
29
|
|
|
5
30
|
### Added
|
package/dist/auth.js
CHANGED
|
@@ -90,6 +90,7 @@ exports.addMeRoutes = exports.addAuthRoutes = exports.setupAuth = exports.genera
|
|
|
90
90
|
var node_crypto_1 = require("node:crypto");
|
|
91
91
|
var express_1 = __importDefault(require("express"));
|
|
92
92
|
var jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
93
|
+
var luxon_1 = require("luxon");
|
|
93
94
|
var ms_1 = __importDefault(require("ms"));
|
|
94
95
|
var passport_1 = __importDefault(require("passport"));
|
|
95
96
|
var passport_anonymous_1 = require("passport-anonymous");
|
|
@@ -247,7 +248,6 @@ var generateTokens = function (user_1, authOptions_1) {
|
|
|
247
248
|
});
|
|
248
249
|
};
|
|
249
250
|
exports.generateTokens = generateTokens;
|
|
250
|
-
// TODO allow customization
|
|
251
251
|
var setupAuth = function (app, userModel) {
|
|
252
252
|
passport_1.default.use(new passport_anonymous_1.Strategy());
|
|
253
253
|
passport_1.default.use(userModel.createStrategy());
|
|
@@ -371,7 +371,7 @@ var setupAuth = function (app, userModel) {
|
|
|
371
371
|
? error.expiredAt
|
|
372
372
|
: undefined;
|
|
373
373
|
message = (0, errors_1.errorMessage)(error);
|
|
374
|
-
details = "[jwt] Error decoding token".concat(userText, ": ").concat(error, ", expired at ").concat(expiredAt, ", current time: ").concat(
|
|
374
|
+
details = "[jwt] Error decoding token".concat(userText, ": ").concat(error, ", expired at ").concat(expiredAt, ", current time: ").concat(luxon_1.DateTime.now().toMillis());
|
|
375
375
|
logger_1.logger.debug(details);
|
|
376
376
|
return [2 /*return*/, res.status(401).json({ details: details, message: message })];
|
|
377
377
|
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __assign = (this && this.__assign) || function () {
|
|
3
|
+
__assign = Object.assign || function(t) {
|
|
4
|
+
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
|
5
|
+
s = arguments[i];
|
|
6
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
|
7
|
+
t[p] = s[p];
|
|
8
|
+
}
|
|
9
|
+
return t;
|
|
10
|
+
};
|
|
11
|
+
return __assign.apply(this, arguments);
|
|
12
|
+
};
|
|
2
13
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
14
|
if (k2 === undefined) k2 = k;
|
|
4
15
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -135,11 +146,7 @@ var buildApp = function (configModel, options) {
|
|
|
135
146
|
var app = (0, tests_1.getBaseServer)();
|
|
136
147
|
(0, auth_1.setupAuth)(app, tests_1.UserModel);
|
|
137
148
|
(0, auth_1.addAuthRoutes)(app, tests_1.UserModel);
|
|
138
|
-
var configApp = new configurationApp_1.ConfigurationApp({
|
|
139
|
-
basePath: options === null || options === void 0 ? void 0 : options.basePath,
|
|
140
|
-
fieldOverrides: options === null || options === void 0 ? void 0 : options.fieldOverrides,
|
|
141
|
-
model: configModel,
|
|
142
|
-
});
|
|
149
|
+
var configApp = new configurationApp_1.ConfigurationApp(__assign({ model: configModel }, options));
|
|
143
150
|
configApp.register(app);
|
|
144
151
|
app.use(errors_1.apiUnauthorizedMiddleware);
|
|
145
152
|
app.use(errors_1.apiErrorMiddleware);
|
|
@@ -268,6 +275,49 @@ var buildApp = function (configModel, options) {
|
|
|
268
275
|
}
|
|
269
276
|
});
|
|
270
277
|
}); });
|
|
278
|
+
(0, bun_test_1.it)("preserves sibling subdoc fields on a partial nested patch", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
279
|
+
var updated;
|
|
280
|
+
return __generator(this, function (_a) {
|
|
281
|
+
switch (_a.label) {
|
|
282
|
+
case 0: return [4 /*yield*/, TestConfig.updateConfig({ general: { appName: "Initial", maintenanceMode: true } })];
|
|
283
|
+
case 1:
|
|
284
|
+
_a.sent();
|
|
285
|
+
return [4 /*yield*/, TestConfig.updateConfig({ general: { appName: "Renamed" } })];
|
|
286
|
+
case 2:
|
|
287
|
+
updated = _a.sent();
|
|
288
|
+
(0, bun_test_1.expect)(updated.general.appName).toBe("Renamed");
|
|
289
|
+
// Sibling must be preserved (not clobbered back to the default).
|
|
290
|
+
(0, bun_test_1.expect)(updated.general.maintenanceMode).toBe(true);
|
|
291
|
+
return [2 /*return*/];
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}); });
|
|
295
|
+
(0, bun_test_1.it)("tolerates legacy/out-of-schema fields already persisted", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
296
|
+
var updated, raw;
|
|
297
|
+
var _a, _b;
|
|
298
|
+
return __generator(this, function (_c) {
|
|
299
|
+
switch (_c.label) {
|
|
300
|
+
case 0:
|
|
301
|
+
// Insert a document containing a field that is not in the (strict: throw) schema.
|
|
302
|
+
return [4 /*yield*/, TestConfig.getConfig()];
|
|
303
|
+
case 1:
|
|
304
|
+
// Insert a document containing a field that is not in the (strict: throw) schema.
|
|
305
|
+
_c.sent();
|
|
306
|
+
return [4 /*yield*/, ((_a = mongoose_1.default.connection.db) === null || _a === void 0 ? void 0 : _a.collection("testconfigs").updateOne({}, { $set: { legacyField: "stale" } }))];
|
|
307
|
+
case 2:
|
|
308
|
+
_c.sent();
|
|
309
|
+
return [4 /*yield*/, TestConfig.updateConfig({ general: { appName: "Survives" } })];
|
|
310
|
+
case 3:
|
|
311
|
+
updated = _c.sent();
|
|
312
|
+
(0, bun_test_1.expect)(updated.general.appName).toBe("Survives");
|
|
313
|
+
return [4 /*yield*/, ((_b = mongoose_1.default.connection.db) === null || _b === void 0 ? void 0 : _b.collection("testconfigs").findOne({}))];
|
|
314
|
+
case 4:
|
|
315
|
+
raw = _c.sent();
|
|
316
|
+
(0, bun_test_1.expect)(raw === null || raw === void 0 ? void 0 : raw.legacyField).toBe("stale");
|
|
317
|
+
return [2 /*return*/];
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}); });
|
|
271
321
|
});
|
|
272
322
|
(0, bun_test_1.describe)("singleton enforcement", function () {
|
|
273
323
|
(0, bun_test_1.it)("prevents creating a second document via save", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
@@ -567,17 +617,23 @@ var buildApp = function (configModel, options) {
|
|
|
567
617
|
}
|
|
568
618
|
});
|
|
569
619
|
}); });
|
|
570
|
-
(0, bun_test_1.it)("
|
|
571
|
-
var res;
|
|
620
|
+
(0, bun_test_1.it)("never persists secret field values supplied in the body", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
621
|
+
var res, stored;
|
|
572
622
|
return __generator(this, function (_a) {
|
|
573
623
|
switch (_a.label) {
|
|
574
624
|
case 0: return [4 /*yield*/, adminAgent
|
|
575
625
|
.patch("/configuration")
|
|
576
|
-
.send({ integrations: { apiKey: "new-secret" } })
|
|
626
|
+
.send({ integrations: { apiKey: "new-secret", webhookUrl: "https://changed.example.com" } })
|
|
577
627
|
.expect(200)];
|
|
578
628
|
case 1:
|
|
579
629
|
res = _a.sent();
|
|
580
|
-
|
|
630
|
+
// Secret stays empty (stripped); non-secret sibling is updated.
|
|
631
|
+
(0, bun_test_1.expect)(res.body.data.integrations.apiKey).toBe("");
|
|
632
|
+
(0, bun_test_1.expect)(res.body.data.integrations.webhookUrl).toBe("https://changed.example.com");
|
|
633
|
+
return [4 /*yield*/, TestConfig.getConfig()];
|
|
634
|
+
case 2:
|
|
635
|
+
stored = _a.sent();
|
|
636
|
+
(0, bun_test_1.expect)(stored.integrations.apiKey).toBe("");
|
|
581
637
|
return [2 /*return*/];
|
|
582
638
|
}
|
|
583
639
|
});
|
|
@@ -597,7 +653,7 @@ var buildApp = function (configModel, options) {
|
|
|
597
653
|
}); });
|
|
598
654
|
});
|
|
599
655
|
(0, bun_test_1.describe)("POST /configuration/list-secrets", function () {
|
|
600
|
-
(0, bun_test_1.it)("returns discovered secret fields", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
656
|
+
(0, bun_test_1.it)("returns discovered secret fields with resolvable status", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
601
657
|
var res;
|
|
602
658
|
return __generator(this, function (_a) {
|
|
603
659
|
switch (_a.label) {
|
|
@@ -607,6 +663,42 @@ var buildApp = function (configModel, options) {
|
|
|
607
663
|
(0, bun_test_1.expect)(res.body.secretFields).toHaveLength(1);
|
|
608
664
|
(0, bun_test_1.expect)(res.body.secretFields[0].path).toBe("integrations.apiKey");
|
|
609
665
|
(0, bun_test_1.expect)(res.body.secretFields[0].secretName).toBe("external-api-key");
|
|
666
|
+
// No provider configured -> not resolvable, and no value is ever returned.
|
|
667
|
+
(0, bun_test_1.expect)(res.body.secretFields[0].resolvable).toBe(false);
|
|
668
|
+
(0, bun_test_1.expect)(res.body.secretFields[0].isConfigured).toBe(false);
|
|
669
|
+
(0, bun_test_1.expect)(JSON.stringify(res.body)).not.toContain("super-secret");
|
|
670
|
+
return [2 /*return*/];
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
}); });
|
|
674
|
+
(0, bun_test_1.it)("does not mutate the stored config document's secret fields", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
675
|
+
var stored;
|
|
676
|
+
return __generator(this, function (_a) {
|
|
677
|
+
switch (_a.label) {
|
|
678
|
+
case 0: return [4 /*yield*/, TestConfig.getConfig()];
|
|
679
|
+
case 1:
|
|
680
|
+
_a.sent();
|
|
681
|
+
return [4 /*yield*/, adminAgent.post("/configuration/list-secrets").expect(200)];
|
|
682
|
+
case 2:
|
|
683
|
+
_a.sent();
|
|
684
|
+
return [4 /*yield*/, TestConfig.getConfig()];
|
|
685
|
+
case 3:
|
|
686
|
+
stored = _a.sent();
|
|
687
|
+
// list-secrets must never write resolved values into the document.
|
|
688
|
+
(0, bun_test_1.expect)(stored.integrations.apiKey).toBe("");
|
|
689
|
+
return [2 /*return*/];
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}); });
|
|
693
|
+
(0, bun_test_1.it)("is also exposed as validate-secrets", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
694
|
+
var res;
|
|
695
|
+
return __generator(this, function (_a) {
|
|
696
|
+
switch (_a.label) {
|
|
697
|
+
case 0: return [4 /*yield*/, adminAgent.post("/configuration/validate-secrets").expect(200)];
|
|
698
|
+
case 1:
|
|
699
|
+
res = _a.sent();
|
|
700
|
+
(0, bun_test_1.expect)(res.body.secretFields).toHaveLength(1);
|
|
701
|
+
(0, bun_test_1.expect)(res.body.secretFields[0].path).toBe("integrations.apiKey");
|
|
610
702
|
return [2 /*return*/];
|
|
611
703
|
}
|
|
612
704
|
});
|
|
@@ -698,3 +790,190 @@ var buildApp = function (configModel, options) {
|
|
|
698
790
|
});
|
|
699
791
|
}); });
|
|
700
792
|
});
|
|
793
|
+
(0, bun_test_1.describe)("ConfigurationApp with custom permissions", function () {
|
|
794
|
+
var app;
|
|
795
|
+
var adminAgent;
|
|
796
|
+
var notAdminAgent;
|
|
797
|
+
// Terreno-style permission: any authenticated user (not just admins).
|
|
798
|
+
var isAuthenticated = function (_method, user) { return Boolean(user === null || user === void 0 ? void 0 : user.id); };
|
|
799
|
+
(0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
800
|
+
var _a;
|
|
801
|
+
return __generator(this, function (_b) {
|
|
802
|
+
switch (_b.label) {
|
|
803
|
+
case 0: return [4 /*yield*/, (0, tests_1.setupDb)()];
|
|
804
|
+
case 1:
|
|
805
|
+
_b.sent();
|
|
806
|
+
return [4 /*yield*/, ((_a = mongoose_1.default.connection.db) === null || _a === void 0 ? void 0 : _a.collection("testconfigs").deleteMany({}))];
|
|
807
|
+
case 2:
|
|
808
|
+
_b.sent();
|
|
809
|
+
app = buildApp(TestConfig, {
|
|
810
|
+
permissions: {
|
|
811
|
+
read: [isAuthenticated],
|
|
812
|
+
// update intentionally left default (admin-only)
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
|
|
816
|
+
case 3:
|
|
817
|
+
adminAgent = _b.sent();
|
|
818
|
+
return [4 /*yield*/, (0, tests_1.authAsUser)(app, "notAdmin")];
|
|
819
|
+
case 4:
|
|
820
|
+
notAdminAgent = _b.sent();
|
|
821
|
+
return [2 /*return*/];
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}); });
|
|
825
|
+
(0, bun_test_1.it)("allows non-admin reads when a custom read permission is supplied", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
826
|
+
var res;
|
|
827
|
+
return __generator(this, function (_a) {
|
|
828
|
+
switch (_a.label) {
|
|
829
|
+
case 0: return [4 /*yield*/, notAdminAgent.get("/configuration").expect(200)];
|
|
830
|
+
case 1:
|
|
831
|
+
res = _a.sent();
|
|
832
|
+
(0, bun_test_1.expect)(res.body.data.general.appName).toBe("Test App");
|
|
833
|
+
return [2 /*return*/];
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
}); });
|
|
837
|
+
(0, bun_test_1.it)("still rejects unauthenticated reads", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
838
|
+
return __generator(this, function (_a) {
|
|
839
|
+
switch (_a.label) {
|
|
840
|
+
case 0: return [4 /*yield*/, (0, supertest_1.default)(app).get("/configuration").expect(401)];
|
|
841
|
+
case 1:
|
|
842
|
+
_a.sent();
|
|
843
|
+
return [2 /*return*/];
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}); });
|
|
847
|
+
(0, bun_test_1.it)("keeps update admin-only by default", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
848
|
+
return __generator(this, function (_a) {
|
|
849
|
+
switch (_a.label) {
|
|
850
|
+
case 0: return [4 /*yield*/, notAdminAgent
|
|
851
|
+
.patch("/configuration")
|
|
852
|
+
.send({ general: { appName: "Nope" } })
|
|
853
|
+
.expect(403)];
|
|
854
|
+
case 1:
|
|
855
|
+
_a.sent();
|
|
856
|
+
return [4 /*yield*/, adminAgent
|
|
857
|
+
.patch("/configuration")
|
|
858
|
+
.send({ general: { appName: "Yes" } })
|
|
859
|
+
.expect(200)];
|
|
860
|
+
case 2:
|
|
861
|
+
_a.sent();
|
|
862
|
+
return [2 /*return*/];
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
}); });
|
|
866
|
+
});
|
|
867
|
+
(0, bun_test_1.describe)("ConfigurationApp with update hooks", function () {
|
|
868
|
+
var app;
|
|
869
|
+
var adminAgent;
|
|
870
|
+
var preUpdateCalls;
|
|
871
|
+
var postUpdateCalls;
|
|
872
|
+
(0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
873
|
+
var _a;
|
|
874
|
+
return __generator(this, function (_b) {
|
|
875
|
+
switch (_b.label) {
|
|
876
|
+
case 0: return [4 /*yield*/, (0, tests_1.setupDb)()];
|
|
877
|
+
case 1:
|
|
878
|
+
_b.sent();
|
|
879
|
+
return [4 /*yield*/, ((_a = mongoose_1.default.connection.db) === null || _a === void 0 ? void 0 : _a.collection("testconfigs").deleteMany({}))];
|
|
880
|
+
case 2:
|
|
881
|
+
_b.sent();
|
|
882
|
+
preUpdateCalls = [];
|
|
883
|
+
postUpdateCalls = [];
|
|
884
|
+
app = buildApp(TestConfig, {
|
|
885
|
+
postUpdate: function (config, prevValue) { return __awaiter(void 0, void 0, void 0, function () {
|
|
886
|
+
return __generator(this, function (_a) {
|
|
887
|
+
postUpdateCalls.push({ config: config, prev: prevValue });
|
|
888
|
+
return [2 /*return*/];
|
|
889
|
+
});
|
|
890
|
+
}); },
|
|
891
|
+
preUpdate: function (body) { return __awaiter(void 0, void 0, void 0, function () {
|
|
892
|
+
var general;
|
|
893
|
+
var _a;
|
|
894
|
+
return __generator(this, function (_b) {
|
|
895
|
+
preUpdateCalls.push(body);
|
|
896
|
+
general = (_a = body.general) !== null && _a !== void 0 ? _a : {};
|
|
897
|
+
return [2 /*return*/, __assign(__assign({}, body), { general: __assign(__assign({}, general), { maintenanceMode: true }) })];
|
|
898
|
+
});
|
|
899
|
+
}); },
|
|
900
|
+
});
|
|
901
|
+
return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
|
|
902
|
+
case 3:
|
|
903
|
+
adminAgent = _b.sent();
|
|
904
|
+
return [2 /*return*/];
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
}); });
|
|
908
|
+
(0, bun_test_1.it)("runs preUpdate to transform the body before applying", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
909
|
+
var res;
|
|
910
|
+
return __generator(this, function (_a) {
|
|
911
|
+
switch (_a.label) {
|
|
912
|
+
case 0: return [4 /*yield*/, adminAgent
|
|
913
|
+
.patch("/configuration")
|
|
914
|
+
.send({ general: { appName: "Hooked" } })
|
|
915
|
+
.expect(200)];
|
|
916
|
+
case 1:
|
|
917
|
+
res = _a.sent();
|
|
918
|
+
(0, bun_test_1.expect)(res.body.data.general.appName).toBe("Hooked");
|
|
919
|
+
(0, bun_test_1.expect)(res.body.data.general.maintenanceMode).toBe(true);
|
|
920
|
+
(0, bun_test_1.expect)(preUpdateCalls).toHaveLength(1);
|
|
921
|
+
return [2 /*return*/];
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
}); });
|
|
925
|
+
(0, bun_test_1.it)("does not persist secret values even if preUpdate re-introduces them", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
926
|
+
var leakyApp, agent, res, stored;
|
|
927
|
+
return __generator(this, function (_a) {
|
|
928
|
+
switch (_a.label) {
|
|
929
|
+
case 0:
|
|
930
|
+
leakyApp = buildApp(TestConfig, {
|
|
931
|
+
preUpdate: function (body) { return (__assign(__assign({}, body), { integrations: __assign(__assign({}, body.integrations), { apiKey: "leaked-secret" }) })); },
|
|
932
|
+
});
|
|
933
|
+
return [4 /*yield*/, (0, tests_1.authAsUser)(leakyApp, "admin")];
|
|
934
|
+
case 1:
|
|
935
|
+
agent = _a.sent();
|
|
936
|
+
return [4 /*yield*/, agent
|
|
937
|
+
.patch("/configuration")
|
|
938
|
+
.send({ general: { appName: "Safe" } })
|
|
939
|
+
.expect(200)];
|
|
940
|
+
case 2:
|
|
941
|
+
res = _a.sent();
|
|
942
|
+
(0, bun_test_1.expect)(res.body.data.general.appName).toBe("Safe");
|
|
943
|
+
(0, bun_test_1.expect)(res.body.data.integrations.apiKey).toBe("");
|
|
944
|
+
return [4 /*yield*/, TestConfig.getConfig()];
|
|
945
|
+
case 3:
|
|
946
|
+
stored = _a.sent();
|
|
947
|
+
(0, bun_test_1.expect)(stored.integrations.apiKey).toBe("");
|
|
948
|
+
return [2 /*return*/];
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
}); });
|
|
952
|
+
(0, bun_test_1.it)("runs postUpdate with redacted config and previous value", function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
953
|
+
var _a, config, prev;
|
|
954
|
+
return __generator(this, function (_b) {
|
|
955
|
+
switch (_b.label) {
|
|
956
|
+
case 0:
|
|
957
|
+
// Seed a secret so we can confirm it is redacted in hook payloads.
|
|
958
|
+
return [4 /*yield*/, TestConfig.updateConfig({ integrations: { apiKey: "should-not-leak" } })];
|
|
959
|
+
case 1:
|
|
960
|
+
// Seed a secret so we can confirm it is redacted in hook payloads.
|
|
961
|
+
_b.sent();
|
|
962
|
+
return [4 /*yield*/, adminAgent
|
|
963
|
+
.patch("/configuration")
|
|
964
|
+
.send({ general: { appName: "Audited" } })
|
|
965
|
+
.expect(200)];
|
|
966
|
+
case 2:
|
|
967
|
+
_b.sent();
|
|
968
|
+
(0, bun_test_1.expect)(postUpdateCalls).toHaveLength(1);
|
|
969
|
+
_a = postUpdateCalls[0], config = _a.config, prev = _a.prev;
|
|
970
|
+
(0, bun_test_1.expect)(config.general.appName).toBe("Audited");
|
|
971
|
+
// Secret values must be redacted in both payloads.
|
|
972
|
+
(0, bun_test_1.expect)(config.integrations.apiKey).toBe("********");
|
|
973
|
+
(0, bun_test_1.expect)(prev.integrations.apiKey).toBe("********");
|
|
974
|
+
(0, bun_test_1.expect)(JSON.stringify(postUpdateCalls)).not.toContain("should-not-leak");
|
|
975
|
+
return [2 /*return*/];
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
}); });
|
|
979
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type express from "express";
|
|
2
2
|
import type { Model } from "mongoose";
|
|
3
|
+
import { type PermissionMethod } from "./permissions";
|
|
3
4
|
import type { TerrenoPlugin } from "./terrenoPlugin";
|
|
4
5
|
/**
|
|
5
6
|
* Metadata for a single configuration field, sent to the frontend.
|
|
@@ -28,6 +29,44 @@ interface ConfigSectionMeta {
|
|
|
28
29
|
export interface ConfigurationMetaResponse {
|
|
29
30
|
sections: ConfigSectionMeta[];
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Per-route permission overrides for ConfigurationApp. Each value is an array of
|
|
34
|
+
* terreno permission functions ({@link PermissionMethod}), AND-combined like
|
|
35
|
+
* `modelRouter` permissions. When a route is omitted, the default admin-only
|
|
36
|
+
* guard applies.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* permissions: {
|
|
41
|
+
* read: [IsStaff],
|
|
42
|
+
* update: [IsSuperUser],
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export interface ConfigurationPermissions {
|
|
47
|
+
/** Guards `GET {basePath}` (current values). */
|
|
48
|
+
read?: PermissionMethod<unknown>[];
|
|
49
|
+
/** Guards `PATCH {basePath}` (update values). */
|
|
50
|
+
update?: PermissionMethod<unknown>[];
|
|
51
|
+
/** Guards `GET {basePath}/meta` (schema metadata). */
|
|
52
|
+
meta?: PermissionMethod<unknown>[];
|
|
53
|
+
/** Guards `POST {basePath}/list-secrets` and `/validate-secrets`. */
|
|
54
|
+
listSecrets?: PermissionMethod<unknown>[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Hook invoked before a configuration update is applied. Receives the incoming
|
|
58
|
+
* (already system-field- and secret-field-stripped) body and the request, and
|
|
59
|
+
* returns the body to apply. Use it to validate or normalize input. Throw an
|
|
60
|
+
* {@link APIError} to reject the update.
|
|
61
|
+
*/
|
|
62
|
+
export type ConfigurationPreUpdateHook = (body: Record<string, unknown>, req: express.Request) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
63
|
+
/**
|
|
64
|
+
* Hook invoked after a configuration update is applied. Receives the updated
|
|
65
|
+
* configuration and the previous value (both with secret values redacted) plus
|
|
66
|
+
* the request, enabling audit logging of who changed what. Secret values are
|
|
67
|
+
* never included.
|
|
68
|
+
*/
|
|
69
|
+
export type ConfigurationPostUpdateHook = (config: Record<string, unknown>, prevValue: Record<string, unknown>, req: express.Request) => void | Promise<void>;
|
|
31
70
|
/**
|
|
32
71
|
* Options for ConfigurationApp.
|
|
33
72
|
*/
|
|
@@ -40,17 +79,37 @@ export interface ConfigurationAppOptions {
|
|
|
40
79
|
fieldOverrides?: Record<string, {
|
|
41
80
|
widget?: string;
|
|
42
81
|
}>;
|
|
82
|
+
/**
|
|
83
|
+
* Per-route permission overrides. Defaults to admin-only for every route when
|
|
84
|
+
* omitted. Supply terreno permission functions (e.g. `[IsStaff]`) to expose
|
|
85
|
+
* configuration to a consumer's own permission system.
|
|
86
|
+
*/
|
|
87
|
+
permissions?: ConfigurationPermissions;
|
|
88
|
+
/** Hook run before an update is applied (validation/normalization). */
|
|
89
|
+
preUpdate?: ConfigurationPreUpdateHook;
|
|
90
|
+
/** Hook run after an update is applied (audit logging). */
|
|
91
|
+
postUpdate?: ConfigurationPostUpdateHook;
|
|
43
92
|
}
|
|
44
93
|
/**
|
|
45
94
|
* TerrenoPlugin that provides configuration management endpoints.
|
|
46
95
|
*
|
|
47
96
|
* Inspects the Mongoose configuration model to auto-generate:
|
|
48
97
|
* - `GET {basePath}/meta` — Schema metadata (sections, fields, types, descriptions)
|
|
49
|
-
* - `GET {basePath}` — Current configuration values
|
|
50
|
-
* - `PATCH {basePath}` — Update configuration values
|
|
51
|
-
* - `POST {basePath}/
|
|
98
|
+
* - `GET {basePath}` — Current configuration values (secret values redacted)
|
|
99
|
+
* - `PATCH {basePath}` — Update configuration values (secret fields stripped; never written)
|
|
100
|
+
* - `POST {basePath}/list-secrets` (alias `POST {basePath}/validate-secrets`) —
|
|
101
|
+
* Read-only status of each secret field (whether the provider can resolve it).
|
|
102
|
+
* This endpoint never resolves values into the document and returns no secret values.
|
|
103
|
+
*
|
|
104
|
+
* By default all endpoints require `Permissions.IsAdmin`. Supply `permissions`
|
|
105
|
+
* to gate routes with a consumer's own permission functions, and `preUpdate`/
|
|
106
|
+
* `postUpdate` hooks to validate and audit-log changes. This makes
|
|
107
|
+
* `ConfigurationApp` suitable as a single, consumer-owned configuration surface
|
|
108
|
+
* that can replace a bespoke config router.
|
|
52
109
|
*
|
|
53
|
-
*
|
|
110
|
+
* Secret values never touch the database, logs, audit payloads, or API
|
|
111
|
+
* responses: secret fields are stripped from incoming updates and redacted on
|
|
112
|
+
* every read.
|
|
54
113
|
*
|
|
55
114
|
* Nested subschemas in the model become separate sections in the metadata,
|
|
56
115
|
* making them renderable as cards/accordions in the admin UI.
|
|
@@ -72,13 +131,21 @@ export interface ConfigurationAppOptions {
|
|
|
72
131
|
* const AppConfig = mongoose.model("AppConfig", configSchema);
|
|
73
132
|
*
|
|
74
133
|
* new TerrenoApp({ userModel: User })
|
|
75
|
-
* .configure(AppConfig
|
|
134
|
+
* .configure(AppConfig, {
|
|
135
|
+
* permissions: {read: [IsStaff], update: [IsSuperUser]},
|
|
136
|
+
* postUpdate: (config, prevValue, req) => auditLog(req.user, prevValue, config),
|
|
137
|
+
* })
|
|
76
138
|
* .start();
|
|
77
139
|
* ```
|
|
78
140
|
*/
|
|
79
141
|
export declare class ConfigurationApp implements TerrenoPlugin {
|
|
80
142
|
private options;
|
|
81
143
|
constructor(options: ConfigurationAppOptions);
|
|
144
|
+
/**
|
|
145
|
+
* Resolves the guard middleware for a route: the consumer's terreno permission
|
|
146
|
+
* functions when supplied, otherwise the default admin-only guard.
|
|
147
|
+
*/
|
|
148
|
+
private guardFor;
|
|
82
149
|
register(app: express.Application): void;
|
|
83
150
|
/**
|
|
84
151
|
* Builds the metadata response by inspecting the model schema.
|