@atlasauth/cli 0.25.1 → 0.27.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 +49 -0
- package/README.md +26 -5
- package/dist/args.d.ts +30 -0
- package/dist/args.js +15 -0
- package/dist/args.js.map +1 -1
- package/dist/commands/config.d.ts +27 -2
- package/dist/commands/config.js +366 -44
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/doctor-scan.d.ts +29 -0
- package/dist/commands/doctor-scan.js +233 -0
- package/dist/commands/doctor-scan.js.map +1 -0
- package/dist/commands/golive.js +16 -4
- package/dist/commands/golive.js.map +1 -1
- package/dist/commands/import-jsonl.d.ts +51 -0
- package/dist/commands/import-jsonl.js +326 -0
- package/dist/commands/import-jsonl.js.map +1 -0
- package/dist/commands/import.js +22 -12
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/init-next.d.ts +4 -0
- package/dist/commands/init-next.js +86 -0
- package/dist/commands/init-next.js.map +1 -0
- package/dist/commands/init.js +119 -25
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/project-commands.js +74 -4
- package/dist/commands/project-commands.js.map +1 -1
- package/dist/commands/setup-guide.d.ts +3 -0
- package/dist/commands/setup-guide.js +38 -0
- package/dist/commands/setup-guide.js.map +1 -0
- package/dist/commands/users.js +12 -1
- package/dist/commands/users.js.map +1 -1
- package/dist/config-http.d.ts +9 -0
- package/dist/config-http.js +7 -1
- package/dist/config-http.js.map +1 -1
- package/dist/context.d.ts +18 -1
- package/dist/context.js +6 -1
- package/dist/context.js.map +1 -1
- package/dist/help.d.ts +1 -1
- package/dist/help.js +79 -12
- package/dist/help.js.map +1 -1
- package/dist/redact.d.ts +41 -0
- package/dist/redact.js +115 -0
- package/dist/redact.js.map +1 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +16 -4
- package/dist/run.js.map +1 -1
- package/package.json +4 -4
package/dist/commands/config.js
CHANGED
|
@@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.DEFAULT_CONFIG_FILES = void 0;
|
|
37
|
+
exports.didYouMean = didYouMean;
|
|
37
38
|
exports.config = config;
|
|
38
39
|
exports.resolveFormat = resolveFormat;
|
|
39
40
|
exports.planConfigImport = planConfigImport;
|
|
40
41
|
exports.diffFields = diffFields;
|
|
42
|
+
exports.printedFields = printedFields;
|
|
41
43
|
exports.readSecretsEnv = readSecretsEnv;
|
|
42
44
|
exports.unresolvedPlaceholders = unresolvedPlaceholders;
|
|
43
45
|
exports.configTypes = configTypes;
|
|
@@ -57,6 +59,9 @@ const serialize_1 = require("../serialize");
|
|
|
57
59
|
const backend_1 = require("@atlasauth/backend");
|
|
58
60
|
const shared_1 = require("./shared");
|
|
59
61
|
const secrets_1 = require("../secrets");
|
|
62
|
+
const redact_1 = require("../redact");
|
|
63
|
+
/** The refusal `rotate_secret` answers when the secret is the one already stored. */
|
|
64
|
+
const isSameSecretRefusal = (err) => err instanceof backend_1.AtlasApiError && err.status === 422 && /must differ/.test(err.message);
|
|
60
65
|
// ───────────────────────────── small helpers ─────────────────────────────
|
|
61
66
|
/** GET a `{ data: [...] }` list envelope, tolerating an absent/garbled body. */
|
|
62
67
|
async function listData(http, path) {
|
|
@@ -130,12 +135,14 @@ function registry() {
|
|
|
130
135
|
async apply(http, config) {
|
|
131
136
|
await http.put('/v1/messaging_providers/email', compact(withoutMarkers(config)));
|
|
132
137
|
},
|
|
133
|
-
|
|
138
|
+
fileKeys: ['transport', 'from_address', 'mode', 'config'],
|
|
139
|
+
reportsSecrets: true,
|
|
134
140
|
},
|
|
135
141
|
// 1. Instance config (auth_config + allowed origins) — the §9.3 singleton.
|
|
136
142
|
{
|
|
137
143
|
kind: 'singleton',
|
|
138
144
|
type: 'instance',
|
|
145
|
+
fileKeys: ['allowed_origins', 'auth_config'],
|
|
139
146
|
async fetchLive(http) {
|
|
140
147
|
const inst = await http.get('/v1/instance');
|
|
141
148
|
const hints = obj(inst.allowed_origin_integrations ?? null);
|
|
@@ -185,6 +192,7 @@ function registry() {
|
|
|
185
192
|
{
|
|
186
193
|
kind: 'singleton',
|
|
187
194
|
type: 'org_settings_schema',
|
|
195
|
+
fileKeys: ['schema'],
|
|
188
196
|
async fetchLive(http) {
|
|
189
197
|
try {
|
|
190
198
|
const s = await http.get('/v1/organization_settings_schema');
|
|
@@ -205,6 +213,7 @@ function registry() {
|
|
|
205
213
|
{
|
|
206
214
|
kind: 'collection',
|
|
207
215
|
type: 'redirect_urls',
|
|
216
|
+
fileKeys: ['url'],
|
|
208
217
|
keyOf: (c) => String(obj(c).url ?? ''),
|
|
209
218
|
async fetchLive(http) {
|
|
210
219
|
const rows = await listData(http, '/v1/redirect_urls');
|
|
@@ -221,6 +230,7 @@ function registry() {
|
|
|
221
230
|
{
|
|
222
231
|
kind: 'collection',
|
|
223
232
|
type: 'oauth_clients',
|
|
233
|
+
fileKeys: [...OAUTH_CLIENT_KEYS, ...OAUTH_CLIENT_WRITE_KEYS],
|
|
224
234
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
225
235
|
async fetchLive(http) {
|
|
226
236
|
const rows = await listData(http, '/v1/oauth_clients');
|
|
@@ -278,12 +288,37 @@ function registry() {
|
|
|
278
288
|
throw err;
|
|
279
289
|
}
|
|
280
290
|
},
|
|
281
|
-
|
|
291
|
+
fileKeys: ['before_user_create'],
|
|
292
|
+
nestedKeys: { before_user_create: ['url', 'timeout_ms', 'fail_closed', 'enabled', 'signing_secret'] },
|
|
293
|
+
reportsSecrets: true,
|
|
282
294
|
},
|
|
283
295
|
// 5. Social / enterprise OAuth providers — key = the catalog provider slug.
|
|
284
296
|
{
|
|
285
297
|
kind: 'collection',
|
|
286
298
|
type: 'oauth_providers',
|
|
299
|
+
fileKeys: [...OAUTH_PROVIDER_KEYS, 'client_secret', 'values', 'settings'],
|
|
300
|
+
validate(config) {
|
|
301
|
+
const { provider, mode } = obj(config);
|
|
302
|
+
if (mode === undefined || mode === 'native' || mode === 'redirect' || hasPlaceholder(mode))
|
|
303
|
+
return [];
|
|
304
|
+
return [`oauth_providers ${String(provider)}: mode must be native or redirect, got ${JSON.stringify(mode)}.`];
|
|
305
|
+
},
|
|
306
|
+
reportsSecrets: true,
|
|
307
|
+
defaulted: ['mode'],
|
|
308
|
+
writeOnly: ['values'],
|
|
309
|
+
warnings(config, live) {
|
|
310
|
+
const c = obj(config);
|
|
311
|
+
const lv = obj(live ?? null);
|
|
312
|
+
const inFile = typeof c.client_secret === 'string' && c.client_secret !== '';
|
|
313
|
+
const out = [];
|
|
314
|
+
if (c.mode === 'redirect' && !inFile && !lv.client_secret) {
|
|
315
|
+
out.push(`oauth_providers ${String(c.provider)}: mode redirect needs a client_secret, and neither the file nor the instance has one.`);
|
|
316
|
+
}
|
|
317
|
+
if ((c.mode ?? lv.mode) === 'native' && inFile) {
|
|
318
|
+
out.push(`oauth_providers ${String(c.provider)}: secret unused in native mode. Remove client_secret, or set mode: redirect.`);
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
},
|
|
287
322
|
keyOf: (c) => String(obj(c).provider ?? ''),
|
|
288
323
|
async fetchLive(http) {
|
|
289
324
|
const rows = await listData(http, '/v1/oauth_providers');
|
|
@@ -292,7 +327,11 @@ function registry() {
|
|
|
292
327
|
.map((r) => ({
|
|
293
328
|
key: String(r.provider ?? ''),
|
|
294
329
|
id: String(r.provider ?? ''),
|
|
295
|
-
config:
|
|
330
|
+
config: {
|
|
331
|
+
...obj(pick(r, OAUTH_PROVIDER_KEYS)),
|
|
332
|
+
...(r.setting_values && Object.keys(r.setting_values).length ? { settings: r.setting_values } : {}),
|
|
333
|
+
...(r.has_secret === true ? { client_secret: SECRET_SET } : {}),
|
|
334
|
+
},
|
|
296
335
|
}));
|
|
297
336
|
},
|
|
298
337
|
create: putProvider,
|
|
@@ -305,13 +344,15 @@ function registry() {
|
|
|
305
344
|
{
|
|
306
345
|
kind: 'collection',
|
|
307
346
|
type: 'custom_oauth_providers',
|
|
347
|
+
fileKeys: [...CUSTOM_PROVIDER_KEYS, ...CUSTOM_PROVIDER_WRITE_KEYS],
|
|
348
|
+
reportsSecrets: true,
|
|
308
349
|
keyOf: (c) => String(obj(c).provider_key ?? ''),
|
|
309
350
|
async fetchLive(http) {
|
|
310
351
|
const rows = await listData(http, '/v1/custom_oauth_providers');
|
|
311
352
|
return rows.map((r) => ({
|
|
312
353
|
key: String(r.provider_key ?? ''),
|
|
313
354
|
id: String(r.id ?? ''),
|
|
314
|
-
config: pick(r, CUSTOM_PROVIDER_KEYS),
|
|
355
|
+
config: { ...obj(pick(r, CUSTOM_PROVIDER_KEYS)), ...(r.has_secret === true ? { client_secret: SECRET_SET } : {}) },
|
|
315
356
|
}));
|
|
316
357
|
},
|
|
317
358
|
async create(http, config) {
|
|
@@ -330,6 +371,7 @@ function registry() {
|
|
|
330
371
|
{
|
|
331
372
|
kind: 'collection',
|
|
332
373
|
type: 'resource_servers',
|
|
374
|
+
fileKeys: [...RESOURCE_SERVER_KEYS, 'publish_resource_metadata', 'access_token_format', 'refresh_token_absolute_ttl_seconds', 'refresh_token_idle_ttl_seconds', 'introspection_client_ids'],
|
|
333
375
|
keyOf: (c) => String(obj(c).identifier ?? ''),
|
|
334
376
|
async fetchLive(http) {
|
|
335
377
|
const rows = await listData(http, '/v1/resource_servers');
|
|
@@ -358,6 +400,7 @@ function registry() {
|
|
|
358
400
|
{
|
|
359
401
|
kind: 'collection',
|
|
360
402
|
type: 'jwt_templates',
|
|
403
|
+
fileKeys: ['name', 'claims'],
|
|
361
404
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
362
405
|
async fetchLive(http) {
|
|
363
406
|
const rows = await listData(http, '/v1/jwt_templates');
|
|
@@ -382,6 +425,7 @@ function registry() {
|
|
|
382
425
|
{
|
|
383
426
|
kind: 'collection',
|
|
384
427
|
type: 'permissions',
|
|
428
|
+
fileKeys: PERMISSION_KEYS,
|
|
385
429
|
keyOf: (c) => String(obj(c).key ?? ''),
|
|
386
430
|
async fetchLive(http) {
|
|
387
431
|
const rows = await listData(http, '/v1/permissions');
|
|
@@ -404,6 +448,7 @@ function registry() {
|
|
|
404
448
|
{
|
|
405
449
|
kind: 'collection',
|
|
406
450
|
type: 'roles',
|
|
451
|
+
fileKeys: [...ROLE_KEYS, 'visibility'],
|
|
407
452
|
keyOf: (c) => String(obj(c).key ?? ''),
|
|
408
453
|
async fetchLive(http) {
|
|
409
454
|
const rows = await listData(http, '/v1/roles');
|
|
@@ -436,6 +481,25 @@ function registry() {
|
|
|
436
481
|
{
|
|
437
482
|
kind: 'collection',
|
|
438
483
|
type: 'webhook_endpoints',
|
|
484
|
+
fileKeys: [...WEBHOOK_KEYS, 'secret'],
|
|
485
|
+
validate(config) {
|
|
486
|
+
const { url, secret } = obj(config);
|
|
487
|
+
if (secret === undefined || secret === null || hasPlaceholder(secret))
|
|
488
|
+
return [];
|
|
489
|
+
if (typeof secret === 'string' && WEBHOOK_SECRET.test(secret))
|
|
490
|
+
return [];
|
|
491
|
+
return [`webhook_endpoints ${String(url)}: secret must start with "whsec_" followed by 32 to 256 characters (letters, digits, + / = _ -). Omit it and Atlas generates one.`];
|
|
492
|
+
},
|
|
493
|
+
// A dry-run rotation tells a new secret from the stored one without revealing either.
|
|
494
|
+
async secretChanged(http, live, config) {
|
|
495
|
+
try {
|
|
496
|
+
await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret?dry_run=true`, { secret: obj(config).secret });
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
return isSameSecretRefusal(err) ? false : null;
|
|
501
|
+
}
|
|
502
|
+
},
|
|
439
503
|
keyOf: (c) => String(obj(c).url ?? ''),
|
|
440
504
|
async fetchLive(http) {
|
|
441
505
|
const rows = await listData(http, '/v1/webhook_endpoints');
|
|
@@ -453,10 +517,22 @@ function registry() {
|
|
|
453
517
|
enabled_events: c.enabled_events ?? [],
|
|
454
518
|
// Sent on create so a paused endpoint is never live, even briefly.
|
|
455
519
|
...(typeof c.active === 'boolean' ? { active: c.active } : {}),
|
|
520
|
+
...(typeof c.secret === 'string' ? { secret: c.secret } : {}),
|
|
456
521
|
});
|
|
457
522
|
},
|
|
458
523
|
async update(http, live, config) {
|
|
459
|
-
|
|
524
|
+
// PATCH leaves the signing secret alone; only rotate_secret changes it.
|
|
525
|
+
const { secret, ...rest } = obj(config);
|
|
526
|
+
await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(rest, true));
|
|
527
|
+
if (typeof secret !== 'string' || !secret)
|
|
528
|
+
return;
|
|
529
|
+
try {
|
|
530
|
+
await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret`, { secret });
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
if (!isSameSecretRefusal(err))
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
460
536
|
},
|
|
461
537
|
async remove(http, live) {
|
|
462
538
|
await http.delete(`/v1/webhook_endpoints/${enc(live.id)}`);
|
|
@@ -466,11 +542,14 @@ function registry() {
|
|
|
466
542
|
{
|
|
467
543
|
kind: 'collection',
|
|
468
544
|
type: 'sso_connections',
|
|
545
|
+
fileKeys: [...SSO_KEYS, ...SSO_WRITE_KEYS],
|
|
546
|
+
reportsSecrets: true,
|
|
547
|
+
writeOnly: ['saml_idp_certificate'],
|
|
469
548
|
keyOf: ssoKey,
|
|
470
549
|
async fetchLive(http) {
|
|
471
550
|
const rows = await listData(http, '/v1/sso_connections');
|
|
472
551
|
return rows.map((r) => {
|
|
473
|
-
const config = pick(r, SSO_KEYS);
|
|
552
|
+
const config = { ...obj(pick(r, SSO_KEYS)), ...(r.has_secret === true ? { oidc_client_secret: SECRET_SET, discourse_secret: SECRET_SET } : {}) };
|
|
474
553
|
return { key: ssoKey(config), id: String(r.id ?? ''), config };
|
|
475
554
|
});
|
|
476
555
|
},
|
|
@@ -491,6 +570,7 @@ function registry() {
|
|
|
491
570
|
{
|
|
492
571
|
kind: 'collection',
|
|
493
572
|
type: 'sso_onboarding_profiles',
|
|
573
|
+
fileKeys: SSO_PROFILE_KEYS,
|
|
494
574
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
495
575
|
async fetchLive(http) {
|
|
496
576
|
let rows;
|
|
@@ -521,6 +601,7 @@ function registry() {
|
|
|
521
601
|
{
|
|
522
602
|
kind: 'collection',
|
|
523
603
|
type: 'billing_plans',
|
|
604
|
+
fileKeys: [...BILLING_KEYS, 'grace_days', 'provider_price_id', 'annual_amount', 'seat_feature_id', 'promotion'],
|
|
524
605
|
keyOf: (c) => String(obj(c).slug ?? ''),
|
|
525
606
|
async fetchLive(http) {
|
|
526
607
|
const rows = await listData(http, '/v1/billing/plans');
|
|
@@ -546,6 +627,7 @@ function registry() {
|
|
|
546
627
|
{
|
|
547
628
|
kind: 'collection',
|
|
548
629
|
type: 'notification_templates',
|
|
630
|
+
fileKeys: NOTIFICATION_KEYS,
|
|
549
631
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
550
632
|
async fetchLive(http) {
|
|
551
633
|
const rows = await listData(http, '/v1/notification_templates');
|
|
@@ -571,6 +653,7 @@ function registry() {
|
|
|
571
653
|
{
|
|
572
654
|
kind: 'collection',
|
|
573
655
|
type: 'email_templates',
|
|
656
|
+
fileKeys: EMAIL_KEYS,
|
|
574
657
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
575
658
|
async fetchLive(http) {
|
|
576
659
|
const rows = await listData(http, '/v1/email_templates');
|
|
@@ -596,6 +679,7 @@ function registry() {
|
|
|
596
679
|
{
|
|
597
680
|
kind: 'collection',
|
|
598
681
|
type: 'organization_policies',
|
|
682
|
+
fileKeys: ORG_POLICY_KEYS,
|
|
599
683
|
keyOf: (c) => String(obj(c).organization_id ?? ''),
|
|
600
684
|
async fetchLive(http) {
|
|
601
685
|
const orgs = await listData(http, '/v1/organizations');
|
|
@@ -648,6 +732,7 @@ function registry() {
|
|
|
648
732
|
{
|
|
649
733
|
kind: 'singleton',
|
|
650
734
|
type: 'cookie_consent',
|
|
735
|
+
fileKeys: COOKIE_CONSENT_KEYS,
|
|
651
736
|
async fetchLive(http) {
|
|
652
737
|
let cc;
|
|
653
738
|
try {
|
|
@@ -677,6 +762,7 @@ function registry() {
|
|
|
677
762
|
{
|
|
678
763
|
kind: 'singleton',
|
|
679
764
|
type: 'sign_in_with_atlas',
|
|
765
|
+
fileKeys: ['enabled', 'connection_id'],
|
|
680
766
|
async fetchLive(http) {
|
|
681
767
|
let live;
|
|
682
768
|
try {
|
|
@@ -705,6 +791,7 @@ function registry() {
|
|
|
705
791
|
{
|
|
706
792
|
kind: 'singleton',
|
|
707
793
|
type: 'signing_key_policy',
|
|
794
|
+
fileKeys: ['prepublish_days'],
|
|
708
795
|
async fetchLive(http) {
|
|
709
796
|
let live;
|
|
710
797
|
try {
|
|
@@ -754,6 +841,20 @@ const OAUTH_CLIENT_KEYS = [
|
|
|
754
841
|
'access_token_format',
|
|
755
842
|
'token_lifetimes',
|
|
756
843
|
];
|
|
844
|
+
/** What a file may also send on an OAuth client: accepted on write, never exported. */
|
|
845
|
+
const OAUTH_CLIENT_WRITE_KEYS = [
|
|
846
|
+
'application_type',
|
|
847
|
+
'client_secret',
|
|
848
|
+
'secret_expires_in_seconds',
|
|
849
|
+
'jwks',
|
|
850
|
+
'jwks_uri',
|
|
851
|
+
'tls_client_auth_subject_dn',
|
|
852
|
+
'frontchannel_logout_session_required',
|
|
853
|
+
'disabled',
|
|
854
|
+
'metadata',
|
|
855
|
+
'external_ref',
|
|
856
|
+
'add_redirect_origins',
|
|
857
|
+
];
|
|
757
858
|
const OAUTH_PROVIDER_KEYS = [
|
|
758
859
|
'provider',
|
|
759
860
|
'enabled',
|
|
@@ -761,6 +862,7 @@ const OAUTH_PROVIDER_KEYS = [
|
|
|
761
862
|
'allow_sign_up',
|
|
762
863
|
'scopes',
|
|
763
864
|
'client_id',
|
|
865
|
+
'mode',
|
|
764
866
|
];
|
|
765
867
|
const RESOURCE_SERVER_KEYS = [
|
|
766
868
|
'identifier',
|
|
@@ -794,6 +896,18 @@ const CUSTOM_PROVIDER_KEYS = [
|
|
|
794
896
|
'allow_sign_in',
|
|
795
897
|
'allow_sign_up',
|
|
796
898
|
];
|
|
899
|
+
const CUSTOM_PROVIDER_WRITE_KEYS = [
|
|
900
|
+
'client_secret',
|
|
901
|
+
'end_session_endpoint',
|
|
902
|
+
'revocation_endpoint',
|
|
903
|
+
'host_params_schema',
|
|
904
|
+
'data_only',
|
|
905
|
+
'scope_separator',
|
|
906
|
+
'userinfo_auth',
|
|
907
|
+
'client_assertion_audience',
|
|
908
|
+
'logout',
|
|
909
|
+
'idp_initiated_login',
|
|
910
|
+
];
|
|
797
911
|
const WEBHOOK_KEYS = ['url', 'enabled_events', 'active'];
|
|
798
912
|
const SSO_KEYS = [
|
|
799
913
|
'type',
|
|
@@ -812,6 +926,29 @@ const SSO_KEYS = [
|
|
|
812
926
|
'claim_role_mappings',
|
|
813
927
|
'default_role_id',
|
|
814
928
|
];
|
|
929
|
+
const SSO_WRITE_KEYS = [
|
|
930
|
+
'oidc_client_secret',
|
|
931
|
+
'saml_idp_certificate',
|
|
932
|
+
'saml_single_logout',
|
|
933
|
+
'saml_idp_slo_url',
|
|
934
|
+
'saml_idp_slo_response_url',
|
|
935
|
+
'saml_idp_slo_binding',
|
|
936
|
+
'saml_idp_slo_soap_url',
|
|
937
|
+
'saml_require_encrypted_assertions',
|
|
938
|
+
'saml_acs_path',
|
|
939
|
+
'require_consent_screen',
|
|
940
|
+
'discourse_secret',
|
|
941
|
+
'provisioning_mode',
|
|
942
|
+
'manual_endpoints',
|
|
943
|
+
'github_access',
|
|
944
|
+
'attribute_mapping',
|
|
945
|
+
'oidc_scopes',
|
|
946
|
+
'profile_mapping',
|
|
947
|
+
'token_endpoint_auth_method',
|
|
948
|
+
'client_assertion_audience',
|
|
949
|
+
'logout',
|
|
950
|
+
'idp_initiated_login',
|
|
951
|
+
];
|
|
815
952
|
const SSO_PROFILE_KEYS = ['name', 'allowed_connection_types', 'organization_id', 'company_name', 'allow_scim', 'intents', 'return_url'];
|
|
816
953
|
const COOKIE_CONSENT_KEYS = ['enabled', 'script_url', 'script_id', 'site_id', 'blocking_mode', 'extra_origins', 'settings_link', 'culture', 'host_aliases'];
|
|
817
954
|
const BILLING_KEYS = [
|
|
@@ -852,18 +989,109 @@ function obj(config) {
|
|
|
852
989
|
}
|
|
853
990
|
/** What a write-only secret looks like on the live side, and in a plan. */
|
|
854
991
|
const SECRET_SET = '(secret set)';
|
|
855
|
-
const
|
|
992
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
856
993
|
/**
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
994
|
+
* Bring a file's secrets and the live document to one comparable shape. A secret
|
|
995
|
+
* value becomes "(secret set)", so a plan never holds it and it compares equal to
|
|
996
|
+
* the live marker. `unverifiable` lists the secrets set on both sides, or that the
|
|
997
|
+
* API never reports (`reported` false): Atlas cannot say whether they differ, so
|
|
998
|
+
* they are not drift, and the plan says so. A secret the API reports as unset stays
|
|
999
|
+
* a difference. An unresolved ${VAR} stays visible (apply refuses it), as does an
|
|
1000
|
+
* empty value.
|
|
860
1001
|
*/
|
|
861
|
-
function
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1002
|
+
function reconcileSecrets(desired, live, reported, prefix = '') {
|
|
1003
|
+
if (!isObj(desired))
|
|
1004
|
+
return { desired, live, unverifiable: [] };
|
|
1005
|
+
const d = { ...desired };
|
|
1006
|
+
const l = isObj(live) ? { ...live } : {};
|
|
1007
|
+
const unverifiable = [];
|
|
1008
|
+
for (const [k, v] of Object.entries(desired)) {
|
|
1009
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
1010
|
+
if (isObj(v)) {
|
|
1011
|
+
const inner = reconcileSecrets(v, l[k], reported, path);
|
|
1012
|
+
d[k] = inner.desired;
|
|
1013
|
+
if (isObj(l[k]))
|
|
1014
|
+
l[k] = inner.live;
|
|
1015
|
+
unverifiable.push(...inner.unverifiable);
|
|
1016
|
+
}
|
|
1017
|
+
else if ((0, redact_1.isSecretKey)(k) && typeof v === 'string' && v !== '' && v !== SECRET_SET && !(0, redact_1.isPlaceholder)(v)) {
|
|
1018
|
+
if (l[k] === SECRET_SET) {
|
|
1019
|
+
d[k] = SECRET_SET;
|
|
1020
|
+
unverifiable.push(path);
|
|
1021
|
+
}
|
|
1022
|
+
else if (l[k] === undefined || l[k] === null) {
|
|
1023
|
+
d[k] = SECRET_SET;
|
|
1024
|
+
if (!reported) {
|
|
1025
|
+
l[k] = SECRET_SET;
|
|
1026
|
+
unverifiable.push(path);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return { desired: d, live: isObj(live) || unverifiable.length ? l : live, unverifiable };
|
|
1032
|
+
}
|
|
1033
|
+
/** The live document without the secret markers and server defaults the file says nothing about. */
|
|
1034
|
+
function withoutUnstated(live, desired, defaulted = []) {
|
|
1035
|
+
if (!isObj(live))
|
|
1036
|
+
return live;
|
|
1037
|
+
const want = isObj(desired) ? desired : {};
|
|
1038
|
+
const out = {};
|
|
1039
|
+
for (const [k, v] of Object.entries(live)) {
|
|
1040
|
+
if (!(k in want) && (v === SECRET_SET || defaulted.includes(k)))
|
|
1041
|
+
continue;
|
|
1042
|
+
out[k] = isObj(v) && isObj(want[k]) ? withoutUnstated(v, want[k]) : v;
|
|
1043
|
+
}
|
|
1044
|
+
return out;
|
|
1045
|
+
}
|
|
1046
|
+
/** How close two key names are (edit distance). */
|
|
1047
|
+
function distance(a, b) {
|
|
1048
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1049
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1050
|
+
const row = [i];
|
|
1051
|
+
for (let j = 1; j <= b.length; j++)
|
|
1052
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1053
|
+
prev = row;
|
|
1054
|
+
}
|
|
1055
|
+
return prev[b.length];
|
|
1056
|
+
}
|
|
1057
|
+
function didYouMean(key, known) {
|
|
1058
|
+
const norm = (x) => x.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
1059
|
+
const best = [...known].sort((a, b) => distance(norm(key), a) - distance(norm(key), b))[0];
|
|
1060
|
+
return best && distance(norm(key), best) <= Math.max(2, Math.floor(key.length / 3)) ? ` (did you mean ${best}?)` : '';
|
|
1061
|
+
}
|
|
1062
|
+
/** The keys in one parsed resource file that no spec knows, each named with its nearest real key. */
|
|
1063
|
+
function unknownFileKeys(spec, doc) {
|
|
1064
|
+
const out = [];
|
|
1065
|
+
const check = (value, known, label) => {
|
|
1066
|
+
if (!isObj(value))
|
|
1067
|
+
return;
|
|
1068
|
+
for (const k of Object.keys(value))
|
|
1069
|
+
if (!known.includes(k))
|
|
1070
|
+
out.push(`${label}: ${k}${didYouMean(k, known)}`);
|
|
1071
|
+
};
|
|
1072
|
+
if (spec.kind === 'collection') {
|
|
1073
|
+
for (const entry of asArray(doc))
|
|
1074
|
+
check(entry, spec.fileKeys, `${spec.type} ${spec.keyOf(entry) || '(no key)'}`);
|
|
1075
|
+
}
|
|
1076
|
+
else if (spec.fileKeys) {
|
|
1077
|
+
check(doc, spec.fileKeys, spec.type);
|
|
1078
|
+
for (const [k, known] of Object.entries(spec.nestedKeys ?? {}))
|
|
1079
|
+
if (isObj(doc))
|
|
1080
|
+
check(doc[k], known, `${spec.type}.${k}`);
|
|
1081
|
+
}
|
|
1082
|
+
return out;
|
|
1083
|
+
}
|
|
1084
|
+
/** The API's rule for a client-chosen webhook signing secret, including its `atlas_<env>_whsec_` form. */
|
|
1085
|
+
const WEBHOOK_SECRET = /^(?:atlas_(?:live|test)_)?whsec_[A-Za-z0-9+/=_-]{32,256}$/;
|
|
1086
|
+
/** A `${VAR}` still in a value: there is nothing to check yet, and apply refuses it on its own. */
|
|
1087
|
+
const hasPlaceholder = (v) => typeof v === 'string' && /\$\{[A-Z0-9_]+\}/.test(v);
|
|
1088
|
+
/** What one resource file's values break, from each spec's own rules. */
|
|
1089
|
+
function invalidFileValues(spec, doc) {
|
|
1090
|
+
if (spec.kind !== 'collection' || !spec.validate)
|
|
1091
|
+
return [];
|
|
1092
|
+
const out = [];
|
|
1093
|
+
for (const entry of asArray(doc))
|
|
1094
|
+
out.push(...spec.validate(entry));
|
|
867
1095
|
return out;
|
|
868
1096
|
}
|
|
869
1097
|
/** A document without any "(secret set)" marker: what an export writes and what apply sends. */
|
|
@@ -910,6 +1138,8 @@ async function putProvider(http, config) {
|
|
|
910
1138
|
// Credential-less providers (siwe, siws, siwb, siwf) take verifier settings instead.
|
|
911
1139
|
if (c.settings && typeof c.settings === 'object' && !Array.isArray(c.settings))
|
|
912
1140
|
body.settings = c.settings;
|
|
1141
|
+
if (c.mode !== undefined)
|
|
1142
|
+
body.mode = c.mode;
|
|
913
1143
|
const provider = String(c.provider ?? '');
|
|
914
1144
|
if (Object.keys(body).length) {
|
|
915
1145
|
await http.put(`/v1/oauth_providers/${enc(provider)}`, body);
|
|
@@ -976,10 +1206,10 @@ function resolveDir(values) {
|
|
|
976
1206
|
return (0, args_1.str)(values, 'dir') || './atlas-config';
|
|
977
1207
|
}
|
|
978
1208
|
/** Build the config HTTP client from the resolved profile credential. */
|
|
979
|
-
function requireHttp(ctx, values) {
|
|
1209
|
+
function requireHttp(ctx, values, onUnknownFields) {
|
|
980
1210
|
const auth = (0, shared_1.resolveAuth)(ctx, values);
|
|
981
1211
|
const make = ctx.makeHttp ?? config_http_1.createConfigHttp;
|
|
982
|
-
return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl });
|
|
1212
|
+
return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl, ...(onUnknownFields ? { onUnknownFields } : {}) });
|
|
983
1213
|
}
|
|
984
1214
|
/** Write every resource type's live config to `dir` (deterministic, secrets omitted); returns the file count. */
|
|
985
1215
|
async function writeExport(http, dir, format) {
|
|
@@ -998,7 +1228,7 @@ async function writeExport(http, dir, format) {
|
|
|
998
1228
|
const entries = (await spec.fetchLive(http)).filter((e) => !e.managedBy);
|
|
999
1229
|
// Deterministic: entries sorted by natural key, keys sorted inside each.
|
|
1000
1230
|
const sorted = [...entries].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
1001
|
-
const doc = sorted.map((e) => (0, serialize_1.canonicalize)(e.config));
|
|
1231
|
+
const doc = sorted.map((e) => (0, serialize_1.canonicalize)(withoutMarkers(e.config)));
|
|
1002
1232
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), (0, serialize_1.serialize)(doc, format));
|
|
1003
1233
|
fileCount += 1;
|
|
1004
1234
|
}
|
|
@@ -1027,6 +1257,16 @@ async function planConfigImport(http, opts) {
|
|
|
1027
1257
|
throw new errors_1.CliError(`Config directory not found: ${dir}. Run 'atlas config export' first.`);
|
|
1028
1258
|
}
|
|
1029
1259
|
const present = new Set((0, node_fs_1.readdirSync)(dir));
|
|
1260
|
+
const files = registry().flatMap((spec) => present.has(`${spec.type}.${ext}`) ? [{ spec, doc: (0, serialize_1.deserialize)((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), 'utf8'), format) }] : []);
|
|
1261
|
+
const unknown = files.flatMap(({ spec, doc }) => unknownFileKeys(spec, doc));
|
|
1262
|
+
if (unknown.length)
|
|
1263
|
+
throw new errors_1.CliError(`Unknown key(s) in the config:\n ${unknown.join('\n ')}`);
|
|
1264
|
+
const invalid = files.flatMap(({ spec, doc }) => invalidFileValues(spec, interpolate(doc, env)));
|
|
1265
|
+
if (invalid.length)
|
|
1266
|
+
throw new errors_1.CliError(`Invalid value(s) in the config:\n ${invalid.join('\n ')}`);
|
|
1267
|
+
const rotate = opts.rotateSecrets === true;
|
|
1268
|
+
const warnings = [];
|
|
1269
|
+
const unverifiableSecrets = [];
|
|
1030
1270
|
const changes = [];
|
|
1031
1271
|
let unchanged = 0;
|
|
1032
1272
|
const skippedDeletes = [];
|
|
@@ -1048,12 +1288,21 @@ async function planConfigImport(http, opts) {
|
|
|
1048
1288
|
unchanged += 1;
|
|
1049
1289
|
continue;
|
|
1050
1290
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1291
|
+
if (liveRaw === null) {
|
|
1292
|
+
const masked = reconcileSecrets(desiredN, undefined, true).desired;
|
|
1293
|
+
changes.push({ type: spec.type, action: 'create', key: '', after: masked, raw: desiredN, apply: () => spec.apply(http, desired, applyOpts) });
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
const seen = withoutUnstated(comparable(norm(liveRaw), desiredN, prune), desiredN);
|
|
1297
|
+
const r = reconcileSecrets(desiredN, seen, spec.reportsSecrets === true);
|
|
1298
|
+
const forced = rotate ? r.unverifiable : [];
|
|
1299
|
+
if (!rotate)
|
|
1300
|
+
unverifiableSecrets.push(...r.unverifiable.map((path) => ({ type: spec.type, key: '', path })));
|
|
1301
|
+
if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, r.live)) {
|
|
1053
1302
|
unchanged += 1;
|
|
1054
1303
|
continue;
|
|
1055
1304
|
}
|
|
1056
|
-
changes.push({ type: spec.type, action:
|
|
1305
|
+
changes.push({ type: spec.type, action: 'update', key: '', before: r.live, after: r.desired, raw: desiredN, changedSecrets: forced, apply: () => spec.apply(http, desired, applyOpts) });
|
|
1057
1306
|
continue;
|
|
1058
1307
|
}
|
|
1059
1308
|
// Collection: diff desired-by-key against live-by-key.
|
|
@@ -1069,19 +1318,40 @@ async function planConfigImport(http, opts) {
|
|
|
1069
1318
|
const liveByKey = new Map(live.map((e) => [e.key, e]));
|
|
1070
1319
|
for (const [key, cfg] of desiredByKey) {
|
|
1071
1320
|
const existing = liveByKey.get(key);
|
|
1321
|
+
warnings.push(...(spec.warnings?.(cfg, existing?.config) ?? []));
|
|
1072
1322
|
if (!existing) {
|
|
1073
|
-
changes.push({ type: spec.type, action: 'create', key, after: cfg, apply: () => spec.create(http, cfg) });
|
|
1323
|
+
changes.push({ type: spec.type, action: 'create', key, after: reconcileSecrets(cfg, undefined, true).desired, raw: cfg, apply: () => spec.create(http, cfg) });
|
|
1324
|
+
continue;
|
|
1074
1325
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1326
|
+
const seen = withoutUnstated(comparable(existing.config, cfg, prune), cfg, spec.defaulted);
|
|
1327
|
+
const r = reconcileSecrets(cfg, seen, spec.reportsSecrets === true);
|
|
1328
|
+
const unverifiable = [...r.unverifiable];
|
|
1329
|
+
const liveSeen = obj(r.live);
|
|
1330
|
+
for (const k of spec.writeOnly ?? []) {
|
|
1331
|
+
if (obj(r.desired)[k] === undefined)
|
|
1332
|
+
continue;
|
|
1333
|
+
liveSeen[k] = obj(r.desired)[k];
|
|
1334
|
+
unverifiable.push(k);
|
|
1335
|
+
}
|
|
1336
|
+
let forced = rotate ? unverifiable : [];
|
|
1337
|
+
let unknownToApi = rotate ? [] : unverifiable;
|
|
1338
|
+
if (spec.secretChanged && unverifiable.length) {
|
|
1339
|
+
const changed = await spec.secretChanged(http, existing, cfg);
|
|
1340
|
+
if (changed !== null) {
|
|
1341
|
+
forced = changed ? unverifiable : [];
|
|
1342
|
+
unknownToApi = [];
|
|
1078
1343
|
}
|
|
1079
|
-
const upd = spec.update;
|
|
1080
|
-
changes.push({ type: spec.type, action: 'update', key, before: comparable(existing.config, cfg, prune), after: cfg, apply: () => upd(http, existing, cfg) });
|
|
1081
1344
|
}
|
|
1082
|
-
|
|
1345
|
+
unverifiableSecrets.push(...unknownToApi.map((path) => ({ type: spec.type, key, path })));
|
|
1346
|
+
if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, liveSeen)) {
|
|
1083
1347
|
unchanged += 1;
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (!spec.update) {
|
|
1351
|
+
throw new errors_1.CliError(`${spec.type} '${key}' changed but is immutable; recreate it instead.`);
|
|
1084
1352
|
}
|
|
1353
|
+
const upd = spec.update;
|
|
1354
|
+
changes.push({ type: spec.type, action: 'update', key, before: liveSeen, after: r.desired, raw: cfg, changedSecrets: forced, apply: () => upd(http, existing, cfg) });
|
|
1085
1355
|
}
|
|
1086
1356
|
for (const entry of live) {
|
|
1087
1357
|
if (desiredByKey.has(entry.key))
|
|
@@ -1100,7 +1370,7 @@ async function planConfigImport(http, opts) {
|
|
|
1100
1370
|
}
|
|
1101
1371
|
}
|
|
1102
1372
|
}
|
|
1103
|
-
return { changes, unchanged, skippedDeletes, managed };
|
|
1373
|
+
return { changes, unchanged, skippedDeletes, managed, warnings, unverifiableSecrets };
|
|
1104
1374
|
}
|
|
1105
1375
|
/** True for a non-empty object whose every leaf is `null` (a file that only clears fields). */
|
|
1106
1376
|
function onlyNulls(x) {
|
|
@@ -1120,7 +1390,6 @@ function onlyNulls(x) {
|
|
|
1120
1390
|
function comparable(live, desired, prune) {
|
|
1121
1391
|
if (prune)
|
|
1122
1392
|
return live;
|
|
1123
|
-
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
1124
1393
|
if (!isObj(live) || !isObj(desired))
|
|
1125
1394
|
return live;
|
|
1126
1395
|
const out = {};
|
|
@@ -1180,6 +1449,7 @@ async function promoteConfig(ctx, values) {
|
|
|
1180
1449
|
const plan = await planConfigImport(target.http, { dir, format, env: ctx.env, prune });
|
|
1181
1450
|
ctx.io.out(`Promoting ${from} -> ${to}`);
|
|
1182
1451
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
|
|
1452
|
+
printPlanNotes(ctx, plan);
|
|
1183
1453
|
if (dryRun)
|
|
1184
1454
|
return 0;
|
|
1185
1455
|
for (const change of plan.changes)
|
|
@@ -1197,8 +1467,9 @@ async function importConfig(ctx, values) {
|
|
|
1197
1467
|
const format = resolveFormat(values);
|
|
1198
1468
|
const dryRun = values['dry-run'] === true;
|
|
1199
1469
|
const prune = values.prune === true;
|
|
1200
|
-
const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true });
|
|
1470
|
+
const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true, rotateSecrets: values['rotate-secret'] === true });
|
|
1201
1471
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
|
|
1472
|
+
printPlanNotes(ctx, plan);
|
|
1202
1473
|
if (dryRun)
|
|
1203
1474
|
return 0;
|
|
1204
1475
|
for (const change of plan.changes)
|
|
@@ -1211,7 +1482,6 @@ async function importConfig(ctx, values) {
|
|
|
1211
1482
|
}
|
|
1212
1483
|
/** Leaf-level differences between two documents, as dotted paths (arrays compare whole). */
|
|
1213
1484
|
function diffFields(before, after, path = '') {
|
|
1214
|
-
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
1215
1485
|
if (isObj(before) && isObj(after)) {
|
|
1216
1486
|
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
|
1217
1487
|
return keys.flatMap((k) => diffFields(before[k], after[k], path ? `${path}.${k}` : k));
|
|
@@ -1223,6 +1493,35 @@ function diffFields(before, after, path = '') {
|
|
|
1223
1493
|
return [{ path: path || '(document)', ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) }];
|
|
1224
1494
|
}
|
|
1225
1495
|
const show = (v) => (v === undefined ? '(unset)' : typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v));
|
|
1496
|
+
/**
|
|
1497
|
+
* The only form a change's fields are printed or serialised in. A secret-named
|
|
1498
|
+
* string is `<redacted>` with a changed flag, whatever the resource; everything else
|
|
1499
|
+
* goes through the deep redactor.
|
|
1500
|
+
*/
|
|
1501
|
+
function printedFields(c) {
|
|
1502
|
+
const reveal = (0, redact_1.revealingSecrets)();
|
|
1503
|
+
const diffs = diffFields(c.before, reveal ? (c.raw ?? c.after) : c.after);
|
|
1504
|
+
const rows = diffs.map((f) => {
|
|
1505
|
+
const leaf = f.path.split('.').pop();
|
|
1506
|
+
const textual = [f.before, f.after].every((v) => v === undefined || v === null || typeof v === 'string');
|
|
1507
|
+
if (!reveal && (0, redact_1.isSecretKey)(leaf) && textual && !(0, redact_1.isPlaceholder)(f.after))
|
|
1508
|
+
return { path: f.path, redacted: true, changed: true };
|
|
1509
|
+
return { path: f.path, ...(f.before === undefined ? {} : { before: (0, redact_1.redactDeep)(f.before, leaf) }), ...(f.after === undefined ? {} : { after: (0, redact_1.redactDeep)(f.after, leaf) }) };
|
|
1510
|
+
});
|
|
1511
|
+
for (const path of c.changedSecrets ?? [])
|
|
1512
|
+
if (!rows.some((r) => r.path === path))
|
|
1513
|
+
rows.push({ path, redacted: true, changed: true });
|
|
1514
|
+
return rows;
|
|
1515
|
+
}
|
|
1516
|
+
const printedLine = (f) => (f.redacted ? `${f.path}: ${redact_1.REDACTED} (changed)` : `${f.path}: ${show(f.before)} -> ${show(f.after)}`);
|
|
1517
|
+
/** The plan's advice: warnings, and the secrets Atlas cannot compare. */
|
|
1518
|
+
function printPlanNotes(ctx, plan) {
|
|
1519
|
+
for (const w of plan.warnings)
|
|
1520
|
+
ctx.io.out(`warning: ${w}`);
|
|
1521
|
+
for (const u of plan.unverifiableSecrets) {
|
|
1522
|
+
ctx.io.out(`note: ${u.type}${u.key ? ` ${u.key}` : ''} ${u.path}: Atlas does not return this value, so a change to it cannot be detected. Send it again with --rotate-secret.`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1226
1525
|
/**
|
|
1227
1526
|
* `atlas config plan`: a Terraform-style, read-only diff of a config directory
|
|
1228
1527
|
* (`atlas config export` output, committed to git) against the live instance.
|
|
@@ -1234,14 +1533,14 @@ async function planConfig(ctx, values, source) {
|
|
|
1234
1533
|
const { dir, format, env } = source;
|
|
1235
1534
|
const where = source.label ?? dir;
|
|
1236
1535
|
const prune = values.prune === true;
|
|
1237
|
-
const plan = await planConfigImport(http, { dir, format, env, prune });
|
|
1536
|
+
const plan = await planConfigImport(http, { dir, format, env, prune, rotateSecrets: values['rotate-secret'] === true });
|
|
1238
1537
|
const deletes = prune ? [] : plan.skippedDeletes;
|
|
1239
1538
|
const all = plan.changes;
|
|
1240
1539
|
const rows = all.map((c) => ({
|
|
1241
1540
|
type: c.type,
|
|
1242
1541
|
key: c.key || null,
|
|
1243
1542
|
action: c.action,
|
|
1244
|
-
fields: c.action === 'update' ?
|
|
1543
|
+
fields: c.action === 'update' ? printedFields(c) : [],
|
|
1245
1544
|
}));
|
|
1246
1545
|
const pending = rows.length;
|
|
1247
1546
|
if (values.json === true) {
|
|
@@ -1251,6 +1550,8 @@ async function planConfig(ctx, values, source) {
|
|
|
1251
1550
|
unchanged: plan.unchanged,
|
|
1252
1551
|
unmanaged_live: deletes.map((d) => ({ type: d.type, key: d.key || null })),
|
|
1253
1552
|
managed_by_platform: plan.managed.map((m) => ({ type: m.type, key: m.key, managed_by: m.managedBy })),
|
|
1553
|
+
warnings: plan.warnings,
|
|
1554
|
+
unverifiable_secrets: plan.unverifiableSecrets.map((u) => ({ type: u.type, key: u.key || null, path: u.path })),
|
|
1254
1555
|
has_changes: pending > 0,
|
|
1255
1556
|
}, null, 2));
|
|
1256
1557
|
}
|
|
@@ -1262,8 +1563,8 @@ async function planConfig(ctx, values, source) {
|
|
|
1262
1563
|
ctx.io.out('');
|
|
1263
1564
|
ctx.io.out(`${sign[c.action]} ${label} (${c.action})`);
|
|
1264
1565
|
if (c.action === 'update') {
|
|
1265
|
-
for (const f of
|
|
1266
|
-
ctx.io.out(` ${
|
|
1566
|
+
for (const f of printedFields(c))
|
|
1567
|
+
ctx.io.out(` ${printedLine(f)}`);
|
|
1267
1568
|
}
|
|
1268
1569
|
}
|
|
1269
1570
|
if (deletes.length) {
|
|
@@ -1278,6 +1579,9 @@ async function planConfig(ctx, values, source) {
|
|
|
1278
1579
|
for (const m of plan.managed)
|
|
1279
1580
|
ctx.io.out(` ${m.type} ${m.key} (${m.managedBy})`);
|
|
1280
1581
|
}
|
|
1582
|
+
if (plan.warnings.length || plan.unverifiableSecrets.length)
|
|
1583
|
+
ctx.io.out('');
|
|
1584
|
+
printPlanNotes(ctx, plan);
|
|
1281
1585
|
if (pending === 0)
|
|
1282
1586
|
ctx.io.out(`No drift: the instance matches ${where}.`);
|
|
1283
1587
|
}
|
|
@@ -1420,10 +1724,21 @@ async function applyCommand(ctx, args) {
|
|
|
1420
1724
|
return planCommand(ctx, args);
|
|
1421
1725
|
const source = await resolveSource(ctx, args);
|
|
1422
1726
|
try {
|
|
1423
|
-
const
|
|
1727
|
+
const onUnknownFields = (n) => ctx.io.err(`atlas: warning: ${n.method} ${n.path} ignored field(s) ${n.fields}${n.suggestions ? ` (did you mean: ${n.suggestions})` : ''}. This instance is lenient; set auth_config.strictRequestBodies to refuse them.`);
|
|
1728
|
+
const kept = keepingSecrets(requireHttp(ctx, values, onUnknownFields), (0, args_1.str)(values, 'out') || (0, secrets_1.productDir)(ctx.env, 'atlas'));
|
|
1424
1729
|
const prune = values.prune === true;
|
|
1425
|
-
const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune, forceHook: values['force-hook'] === true });
|
|
1730
|
+
const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune, forceHook: values['force-hook'] === true, rotateSecrets: values['rotate-secret'] === true });
|
|
1426
1731
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun: false, prune });
|
|
1732
|
+
for (const c of plan.changes) {
|
|
1733
|
+
if (c.action !== 'update')
|
|
1734
|
+
continue;
|
|
1735
|
+
const fields = printedFields(c);
|
|
1736
|
+
if (fields.length)
|
|
1737
|
+
ctx.io.out(` ~ ${c.key ? `${c.type} ${c.key}` : c.type}:`);
|
|
1738
|
+
for (const f of fields)
|
|
1739
|
+
ctx.io.out(` ${printedLine(f)}`);
|
|
1740
|
+
}
|
|
1741
|
+
printPlanNotes(ctx, plan);
|
|
1427
1742
|
if (plan.changes.length === 0) {
|
|
1428
1743
|
ctx.io.out('No changes to apply.');
|
|
1429
1744
|
return 0;
|
|
@@ -1471,7 +1786,8 @@ function keepingSecrets(http, dir) {
|
|
|
1471
1786
|
if (path === '/v1/oauth_clients' && typeof r.client_secret === 'string') {
|
|
1472
1787
|
entries = { ATLAS_CLIENT_ID: String(r.client_id ?? ''), ATLAS_CLIENT_SECRET: r.client_secret };
|
|
1473
1788
|
}
|
|
1474
|
-
else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string') {
|
|
1789
|
+
else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string' && !body?.secret) {
|
|
1790
|
+
// A secret the file chose is already the caller's; only a minted one needs keeping.
|
|
1475
1791
|
entries = { ATLAS_WEBHOOK_SECRET: r.secret };
|
|
1476
1792
|
}
|
|
1477
1793
|
if (entries) {
|
|
@@ -1515,16 +1831,22 @@ function asArray(value) {
|
|
|
1515
1831
|
* on disk. An unset variable is left verbatim so the plan surfaces it rather
|
|
1516
1832
|
* than silently blanking a credential.
|
|
1517
1833
|
*/
|
|
1518
|
-
function interpolate(value, env) {
|
|
1834
|
+
function interpolate(value, env, key) {
|
|
1519
1835
|
if (typeof value === 'string') {
|
|
1520
|
-
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) =>
|
|
1836
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) => {
|
|
1837
|
+
const resolved = env[name];
|
|
1838
|
+
if (resolved === undefined)
|
|
1839
|
+
return match;
|
|
1840
|
+
(0, redact_1.rememberResolved)(key, resolved);
|
|
1841
|
+
return resolved;
|
|
1842
|
+
});
|
|
1521
1843
|
}
|
|
1522
1844
|
if (Array.isArray(value))
|
|
1523
|
-
return value.map((v) => interpolate(v, env));
|
|
1845
|
+
return value.map((v) => interpolate(v, env, key));
|
|
1524
1846
|
if (value !== null && typeof value === 'object') {
|
|
1525
1847
|
const out = {};
|
|
1526
1848
|
for (const [k, v] of Object.entries(value))
|
|
1527
|
-
out[k] = interpolate(v, env);
|
|
1849
|
+
out[k] = interpolate(v, env, k);
|
|
1528
1850
|
return out;
|
|
1529
1851
|
}
|
|
1530
1852
|
return value;
|