@codyswann/lisa 4.29.2 → 4.29.4
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/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +55 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +202 -16
- package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +589 -100
- package/plugins/src/base/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/scripts/two-channel-couplings.json +1 -1
package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs
CHANGED
|
@@ -36,10 +36,12 @@ import { fileURLToPath } from "node:url";
|
|
|
36
36
|
import {
|
|
37
37
|
BOOTSTRAP_KEY,
|
|
38
38
|
deriveAwsEnvironment,
|
|
39
|
+
RESOLVE_ONLY_LEGACY_SOURCE_PROFILE,
|
|
39
40
|
parseBootstrap,
|
|
40
41
|
renderAwsProfiles,
|
|
41
42
|
} from "./aws-bootstrap.mjs";
|
|
42
43
|
import { renderEnv, renderNotes } from "./envfile.mjs";
|
|
44
|
+
import { assertOwner, ownerOf, ownerTag } from "./owner.mjs";
|
|
43
45
|
import { fetchAll } from "./providers.mjs";
|
|
44
46
|
import { materializedPaths, readConfig } from "./surfaces.mjs";
|
|
45
47
|
|
|
@@ -98,7 +100,7 @@ function writeAtomic(destination, contents) {
|
|
|
98
100
|
* carrying orphans from a past rename is repaired on the next run rather than
|
|
99
101
|
* accumulating one more.
|
|
100
102
|
*/
|
|
101
|
-
const MARKER_VERSION = "
|
|
103
|
+
const MARKER_VERSION = "v3";
|
|
102
104
|
|
|
103
105
|
/**
|
|
104
106
|
* The frozen half of each marker. Changing one of these DOES orphan blocks —
|
|
@@ -131,37 +133,44 @@ function familyRecognisers(family) {
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/**
|
|
134
|
-
* Index of the first family match at or after `from`, and its
|
|
136
|
+
* Index of the first family match at or after `from`, its length, and its text.
|
|
137
|
+
*
|
|
138
|
+
* The matched text is returned because the owner lives inside the marker, and
|
|
139
|
+
* re-slicing it at every call site is how a reader ends up owner-blind in one
|
|
140
|
+
* of them.
|
|
135
141
|
* @param {string} text Haystack.
|
|
136
142
|
* @param {RegExp} recogniser Global family recogniser.
|
|
137
143
|
* @param {number} from Index to search from.
|
|
138
|
-
* @returns {{index: number, length: number}} `index` is -1 when absent.
|
|
144
|
+
* @returns {{index: number, length: number, text: string}} `index` is -1 when absent.
|
|
139
145
|
*/
|
|
140
146
|
function findFamily(text, recogniser, from = 0) {
|
|
141
147
|
recogniser.lastIndex = from;
|
|
142
148
|
const match = recogniser.exec(text);
|
|
143
149
|
return match === null
|
|
144
|
-
? { index: -1, length: 0 }
|
|
145
|
-
: { index: match.index, length: match[0].length };
|
|
150
|
+
? { index: -1, length: 0, text: "" }
|
|
151
|
+
: { index: match.index, length: match[0].length, text: match[0] };
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
/**
|
|
149
|
-
*
|
|
155
|
+
* Every managed block of one family, in file order, with its owner.
|
|
150
156
|
*
|
|
151
|
-
* Every block, not just the first: a file
|
|
152
|
-
*
|
|
153
|
-
*
|
|
157
|
+
* Every block, not just the first: a file may carry orphans from a rename that
|
|
158
|
+
* predates the family recogniser, AND — since a machine can serve more than one
|
|
159
|
+
* tenant — blocks belonging to other projects that must be left exactly alone.
|
|
160
|
+
* Those are different populations with different handling, so the reader
|
|
161
|
+
* enumerates rather than assuming.
|
|
154
162
|
* @param {string} text File contents.
|
|
155
163
|
* @param {string} family Frozen family identifier.
|
|
156
|
-
* @returns {
|
|
164
|
+
* @returns {Array<{start: number, end: number, owner: string|null, body: string}>} Blocks.
|
|
157
165
|
*/
|
|
158
|
-
function
|
|
166
|
+
function familyBlocks(text, family) {
|
|
159
167
|
const { start, end } = familyRecognisers(family);
|
|
160
|
-
|
|
168
|
+
const blocks = [];
|
|
169
|
+
let from = 0;
|
|
161
170
|
for (;;) {
|
|
162
|
-
const opened = findFamily(
|
|
163
|
-
if (opened.index === -1) return
|
|
164
|
-
const closed = findFamily(
|
|
171
|
+
const opened = findFamily(text, start, from);
|
|
172
|
+
if (opened.index === -1) return blocks;
|
|
173
|
+
const closed = findFamily(text, end, opened.index + opened.length);
|
|
165
174
|
// A truncated block (opened, never closed) is not safe to interpret. Any
|
|
166
175
|
// guessed boundary could delete operator-authored content after the marker,
|
|
167
176
|
// so refuse before the caller writes and preserve the original byte-for-byte.
|
|
@@ -171,56 +180,141 @@ function stripFamilyBlocks(text, family) {
|
|
|
171
180
|
`Repair the marker pair first; no file was changed.`
|
|
172
181
|
);
|
|
173
182
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
183
|
+
const stop = closed.index + closed.length;
|
|
184
|
+
blocks.push({
|
|
185
|
+
start: opened.index,
|
|
186
|
+
end: stop,
|
|
187
|
+
owner: ownerOf(opened.text),
|
|
188
|
+
body: text.slice(opened.index, stop),
|
|
189
|
+
});
|
|
190
|
+
from = stop;
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
|
|
179
|
-
/**
|
|
180
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Remove the family blocks a predicate selects, leaving the rest of the file.
|
|
196
|
+
*
|
|
197
|
+
* Selective rather than total, and that is the fix. "Our own previous output is
|
|
198
|
+
* meant to be replaced" was true and unqualified by WHOSE: a second tenant's
|
|
199
|
+
* run matched the same unowned marker and stripped the first tenant's block.
|
|
200
|
+
* @param {string} text File contents.
|
|
201
|
+
* @param {string} family Frozen family identifier.
|
|
202
|
+
* @param {(block: {owner: string|null, body: string}) => boolean} selects Which blocks to remove.
|
|
203
|
+
* @returns {string} The file with the selected blocks removed.
|
|
204
|
+
*/
|
|
205
|
+
function stripFamilyBlocks(text, family, selects) {
|
|
206
|
+
// Back to front, so an earlier block's offsets are still valid after a later
|
|
207
|
+
// one is cut.
|
|
208
|
+
const doomed = familyBlocks(text, family).filter(block => selects(block));
|
|
209
|
+
let out = text;
|
|
210
|
+
for (const block of doomed.reverse()) {
|
|
211
|
+
const after = out.slice(block.end).replace(/^\n/, "");
|
|
212
|
+
out = `${out.slice(0, block.start)}${after}`;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Marks the block one owner owns, so it is replaced rather than appended twice.
|
|
219
|
+
* @param {string} owner Validated owner.
|
|
220
|
+
* @returns {string} The opening marker.
|
|
221
|
+
*/
|
|
222
|
+
function profileMarker(owner) {
|
|
223
|
+
return `# >>> ${PROFILE_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)}) >>>`;
|
|
224
|
+
}
|
|
181
225
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Closes one owner's managed shell block.
|
|
228
|
+
* @param {string} owner Validated owner.
|
|
229
|
+
* @returns {string} The closing marker.
|
|
230
|
+
*/
|
|
231
|
+
function profileEnd(owner) {
|
|
232
|
+
return `# <<< ${PROFILE_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)}) <<<`;
|
|
233
|
+
}
|
|
184
234
|
|
|
185
235
|
/**
|
|
186
|
-
* Identifies an `~/.aws`
|
|
236
|
+
* Identifies an `~/.aws` region as one this wrote FOR A GIVEN OWNER.
|
|
187
237
|
*
|
|
188
238
|
* `#` is a comment in the AWS shared-config format, so this is inert to every
|
|
189
239
|
* consumer while still being the thing that distinguishes "our file, refresh
|
|
190
|
-
* it" from "someone else's file, leave it alone"
|
|
240
|
+
* it" from "someone else's file, leave it alone" — and now also from "another
|
|
241
|
+
* Lisa project's, leave it alone too", which is the case that was missing.
|
|
242
|
+
* @param {string} owner Validated owner.
|
|
243
|
+
* @returns {string} The opening marker.
|
|
191
244
|
*/
|
|
192
|
-
|
|
245
|
+
function managedMarker(owner) {
|
|
246
|
+
return `# >>> ${MANAGED_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)} >>>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Closes one owner's managed region of an `~/.aws` file.
|
|
251
|
+
* @param {string} owner Validated owner.
|
|
252
|
+
* @returns {string} The closing marker.
|
|
253
|
+
*/
|
|
254
|
+
function managedEnd(owner) {
|
|
255
|
+
return `# <<< ${MANAGED_FAMILY} ${MARKER_VERSION} ${ownerTag(owner)} <<<`;
|
|
256
|
+
}
|
|
193
257
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Whether a block belongs to `owner`, for stripping.
|
|
260
|
+
*
|
|
261
|
+
* An unowned block is NEVER ours here. It predates ownership, so it may belong
|
|
262
|
+
* to any tenant on this machine, and adopting it is exactly the silent
|
|
263
|
+
* consumption this change exists to stop. It is reported instead, and removed
|
|
264
|
+
* only under an explicit prune.
|
|
265
|
+
* @param {string} owner Our owner.
|
|
266
|
+
* @returns {(candidate: string|null) => boolean} Predicate over block owners.
|
|
267
|
+
*/
|
|
268
|
+
function ownedBy(owner) {
|
|
269
|
+
return block => block.owner === owner;
|
|
270
|
+
}
|
|
196
271
|
|
|
197
272
|
/**
|
|
198
|
-
* Replace
|
|
273
|
+
* Replace one owner's delimited region in a file, preserving everything else.
|
|
199
274
|
*
|
|
200
275
|
* Written as a merge rather than a whole-file write because both `~/.aws` files
|
|
201
276
|
* routinely hold sections nobody here knows about — an operator's own profiles,
|
|
202
|
-
*
|
|
203
|
-
* all; overwriting would delete them. This does
|
|
277
|
+
* a container's bare `[default]`, or another Lisa project's block. Refusing on
|
|
278
|
+
* their account wrote nothing at all; overwriting would delete them. This does
|
|
279
|
+
* neither.
|
|
204
280
|
* @param {string} current Existing file contents, or "".
|
|
205
|
-
* @param {string} body The region this
|
|
281
|
+
* @param {string} body The region this owner owns.
|
|
282
|
+
* @param {string} owner Validated owner.
|
|
283
|
+
* @param {boolean} [claimLegacy] Also replace pre-ownership blocks.
|
|
206
284
|
* @returns {string} The merged file.
|
|
207
285
|
*/
|
|
208
|
-
export function upsertManagedBlock(current, body) {
|
|
209
|
-
const
|
|
210
|
-
const
|
|
286
|
+
export function upsertManagedBlock(current, body, owner, claimLegacy = false) {
|
|
287
|
+
const scope = assertOwner(owner, "~/.aws");
|
|
288
|
+
const block = `${managedMarker(scope)}\n${body.trimEnd()}\n${managedEnd(scope)}`;
|
|
289
|
+
|
|
290
|
+
// Legacy first, and OUR position measured after it. Removing a pre-ownership
|
|
291
|
+
// block that sits ahead of ours moves ours, so a position taken before the
|
|
292
|
+
// removal would splice the replacement into the wrong place.
|
|
293
|
+
const withoutLegacy = claimLegacy
|
|
294
|
+
? stripFamilyBlocks(current, MANAGED_FAMILY, block => block.owner === null)
|
|
295
|
+
: current;
|
|
296
|
+
const mine = familyBlocks(withoutLegacy, MANAGED_FAMILY).find(
|
|
297
|
+
found => found.owner === scope
|
|
298
|
+
);
|
|
299
|
+
// Only ours. Everything else in the family belongs to another project.
|
|
300
|
+
const withoutOurs = stripFamilyBlocks(
|
|
301
|
+
withoutLegacy,
|
|
302
|
+
MANAGED_FAMILY,
|
|
303
|
+
ownedBy(scope)
|
|
304
|
+
);
|
|
211
305
|
|
|
212
|
-
if (
|
|
306
|
+
if (mine === undefined) {
|
|
213
307
|
const prefix =
|
|
214
|
-
|
|
308
|
+
withoutOurs && !withoutOurs.endsWith("\n")
|
|
309
|
+
? `${withoutOurs}\n`
|
|
310
|
+
: withoutOurs;
|
|
215
311
|
return `${prefix}${prefix ? "\n" : ""}${block}\n`;
|
|
216
312
|
}
|
|
217
313
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
const head = withoutOurs.slice(0, opened.index);
|
|
223
|
-
const tail = withoutOurs.slice(opened.index);
|
|
314
|
+
// Nothing before our first block is ours, so stripping ours cannot have moved
|
|
315
|
+
// anything ahead of it — the original offset still points where it did.
|
|
316
|
+
const head = withoutOurs.slice(0, mine.start);
|
|
317
|
+
const tail = withoutOurs.slice(mine.start);
|
|
224
318
|
return `${head}${block}\n${tail}`;
|
|
225
319
|
}
|
|
226
320
|
|
|
@@ -244,13 +338,17 @@ export function upsertManagedBlock(current, body) {
|
|
|
244
338
|
export function installProfileSourcing(valuesFile, options = {}) {
|
|
245
339
|
const {
|
|
246
340
|
home = process.env.HOME || homedir(),
|
|
341
|
+
owner,
|
|
342
|
+
claimShell = process.env.LISA_SECRETS_CLAIM_SHELL_PROFILE === "1",
|
|
247
343
|
exists = existsSync,
|
|
248
344
|
read = readFileSync,
|
|
249
345
|
write = writeFileSync,
|
|
250
346
|
} = options;
|
|
251
347
|
|
|
348
|
+
const scope = assertOwner(owner, "the shell profile block");
|
|
349
|
+
|
|
252
350
|
const block = [
|
|
253
|
-
|
|
351
|
+
profileMarker(scope),
|
|
254
352
|
// Unset BEFORE sourcing. A cloud container injects its own AWS key pair,
|
|
255
353
|
// and environment credentials outrank both ~/.aws profiles and AWS_PROFILE
|
|
256
354
|
// — so leaving them set means the session ignores whichever environment it
|
|
@@ -270,27 +368,37 @@ export function installProfileSourcing(valuesFile, options = {}) {
|
|
|
270
368
|
` . "${valuesFile}"`,
|
|
271
369
|
` set +a`,
|
|
272
370
|
`fi`,
|
|
273
|
-
|
|
371
|
+
profileEnd(scope),
|
|
274
372
|
].join("\n");
|
|
275
373
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
374
|
+
const files = [".bashrc", ".profile"].map(name => join(home, name));
|
|
375
|
+
const contents = files.map(file =>
|
|
376
|
+
exists(file) ? String(read(file, "utf8")) : ""
|
|
377
|
+
);
|
|
280
378
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
379
|
+
// Every file is inspected BEFORE any is written. A shell profile is not a
|
|
380
|
+
// namespaced resource — it exports into every shell on the machine, so if one
|
|
381
|
+
// of the two belongs to another tenant the whole run must stop, not stop
|
|
382
|
+
// halfway with `.bashrc` rewritten and `.profile` not.
|
|
383
|
+
for (const [index, current] of contents.entries()) {
|
|
384
|
+
assertShellUnclaimed(files[index], current, scope, claimShell);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const updated = [];
|
|
388
|
+
for (const [index, file] of files.entries()) {
|
|
389
|
+
const current = contents[index];
|
|
390
|
+
// Ours, plus any legacy block this owner can prove is its own, plus — only
|
|
391
|
+
// under an explicit claim — another tenant's. Stripping every family block
|
|
392
|
+
// unconditionally is what let a second tenant delete the first's.
|
|
393
|
+
const selects = block =>
|
|
394
|
+
claimShell || [scope, null].includes(shellOwnerOf(block));
|
|
395
|
+
const mine = familyBlocks(current, PROFILE_FAMILY).find(selects);
|
|
396
|
+
const withoutOurs = stripFamilyBlocks(current, PROFILE_FAMILY, selects);
|
|
289
397
|
const next =
|
|
290
|
-
|
|
398
|
+
mine === undefined
|
|
291
399
|
? `${current}${current.endsWith("\n") || !current ? "" : "\n"}\n${block}\n`
|
|
292
|
-
: `${withoutOurs.slice(0,
|
|
293
|
-
.slice(
|
|
400
|
+
: `${withoutOurs.slice(0, mine.start)}${block}\n${withoutOurs
|
|
401
|
+
.slice(mine.start)
|
|
294
402
|
.replace(/^\n/, "")}`;
|
|
295
403
|
|
|
296
404
|
if (next !== current) {
|
|
@@ -301,6 +409,73 @@ export function installProfileSourcing(valuesFile, options = {}) {
|
|
|
301
409
|
return updated;
|
|
302
410
|
}
|
|
303
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Who one shell block belongs to, marked or recoverable.
|
|
414
|
+
*
|
|
415
|
+
* A legacy `~/.aws` block carries no tell of who wrote it — bare stage names
|
|
416
|
+
* name a stage and no owner — which is why those are attributed by account id
|
|
417
|
+
* instead. A legacy SHELL block is different: its body sources
|
|
418
|
+
* `<config root>/<tenant>/secrets.env`, so it states its own owner in the one
|
|
419
|
+
* line that matters. Reading it back removes the ambiguity entirely, which is
|
|
420
|
+
* better than a flag, and it lets an existing single-tenant machine upgrade in
|
|
421
|
+
* one silent step rather than failing until an operator intervenes.
|
|
422
|
+
*
|
|
423
|
+
* `null` means unattributable, and that is treated as OURS to claim rather than
|
|
424
|
+
* as a stranger's. Every block this module has ever written names a tenant
|
|
425
|
+
* directory, so a block that names none is not a well-formed block of any
|
|
426
|
+
* tenant — while leaving it behind would break the rule that matters most here:
|
|
427
|
+
* an orphaned block in a shell profile is still sourced, and the last
|
|
428
|
+
* assignment wins.
|
|
429
|
+
* @param {{owner: string|null, body: string}} block One managed block.
|
|
430
|
+
* @returns {string|null} The owner, or null when it cannot be attributed.
|
|
431
|
+
*/
|
|
432
|
+
function shellOwnerOf(block) {
|
|
433
|
+
if (block.owner !== null) return block.owner;
|
|
434
|
+
// The tenant directory is the segment before `secrets.env`; that path is the
|
|
435
|
+
// only place a pre-ownership block records whose values it loads.
|
|
436
|
+
const found = /[/\\]([^/\\]+)[/\\]secrets\.env/.exec(block.body);
|
|
437
|
+
return found === null ? null : found[1];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Stop before redirecting a machine that already serves another tenant.
|
|
442
|
+
*
|
|
443
|
+
* `~/.aws` profiles can be namespaced, so two tenants coexist there and a wrong
|
|
444
|
+
* resolution becomes impossible. A shell profile cannot: it exports into every
|
|
445
|
+
* shell and there is no name for a consumer to select, so it is a single slot
|
|
446
|
+
* two projects contend for — the same problem `[default]` poses in
|
|
447
|
+
* `~/.aws/config`, and it gets the same answer. Claimed when unowned or already
|
|
448
|
+
* ours; otherwise the run stops and names the tenant that holds it.
|
|
449
|
+
*
|
|
450
|
+
* Taking it over silently is the whole defect in miniature: every shell on the
|
|
451
|
+
* machine would start exporting another project's credentials, and the sessions
|
|
452
|
+
* that lost their block would carry on believing they had their own.
|
|
453
|
+
* @param {string} file The shell profile path, for the message.
|
|
454
|
+
* @param {string} current File contents.
|
|
455
|
+
* @param {string} scope Our owner.
|
|
456
|
+
* @param {boolean} claimShell Whether the operator asked to take it over.
|
|
457
|
+
*/
|
|
458
|
+
function assertShellUnclaimed(file, current, scope, claimShell) {
|
|
459
|
+
if (claimShell) return;
|
|
460
|
+
const others = [
|
|
461
|
+
...new Set(
|
|
462
|
+
familyBlocks(current, PROFILE_FAMILY)
|
|
463
|
+
.map(shellOwnerOf)
|
|
464
|
+
.filter(found => found !== null)
|
|
465
|
+
),
|
|
466
|
+
].filter(found => found !== scope);
|
|
467
|
+
if (others.length === 0) return;
|
|
468
|
+
throw new Error(
|
|
469
|
+
`${file} already loads secrets for ${others.map(n => `"${n}"`).join(", ")}, ` +
|
|
470
|
+
`not "${scope}".\n` +
|
|
471
|
+
`A shell profile is read by every shell on this machine, so rewriting it ` +
|
|
472
|
+
`would hand ${scope}'s credentials to that project's sessions too — and ` +
|
|
473
|
+
`they would report ready, because the credentials work.\n` +
|
|
474
|
+
`Re-run with LISA_SECRETS_CLAIM_SHELL_PROFILE=1 to switch this machine ` +
|
|
475
|
+
`to "${scope}" deliberately. Nothing was changed.`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
304
479
|
/**
|
|
305
480
|
* Write `~/.aws/credentials` and `~/.aws/config` from the bootstrap bundle.
|
|
306
481
|
*
|
|
@@ -311,37 +486,118 @@ export function installProfileSourcing(valuesFile, options = {}) {
|
|
|
311
486
|
* @returns {string[]} The profile names written.
|
|
312
487
|
*/
|
|
313
488
|
/**
|
|
314
|
-
* Profile names already defined
|
|
489
|
+
* Profile names already defined outside the block this owner is about to write.
|
|
315
490
|
*
|
|
316
|
-
* Only what lies outside
|
|
491
|
+
* Only what lies outside OUR block counts: our own previous output is meant to
|
|
317
492
|
* be replaced, and treating it as a collision would make the second run fail.
|
|
493
|
+
* What changed is that "ours" is now scoped to one owner, so another project's
|
|
494
|
+
* block reads as a collision instead of as our own output.
|
|
495
|
+
*
|
|
496
|
+
* Each hit reports who holds the name: `null` for a section an operator wrote
|
|
497
|
+
* outside any managed block, or the owning project. The two need different
|
|
498
|
+
* advice, and a message that guessed would send the reader to the wrong file.
|
|
318
499
|
* @param {string} dir The `.aws` directory.
|
|
319
500
|
* @param {string[]} names Profile names about to be written.
|
|
320
|
-
* @param {object} io `exists` and `
|
|
321
|
-
* @returns {string
|
|
501
|
+
* @param {object} io `exists`, `read`, `owner`, and `includeLegacy` seams.
|
|
502
|
+
* @returns {Array<{name: string, owner: string|null}>} Collisions, in the order given.
|
|
322
503
|
*/
|
|
323
504
|
export function collidingProfiles(dir, names, io = {}) {
|
|
324
|
-
const {
|
|
505
|
+
const {
|
|
506
|
+
exists = existsSync,
|
|
507
|
+
read = readFileSync,
|
|
508
|
+
owner = null,
|
|
509
|
+
includeLegacy = false,
|
|
510
|
+
} = io;
|
|
325
511
|
const file = join(dir, "config");
|
|
326
512
|
if (!exists(file)) return [];
|
|
327
513
|
|
|
328
514
|
const text = String(read(file, "utf8"));
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
515
|
+
// Our own previous output is meant to be replaced, so counting its profiles
|
|
516
|
+
// as a collision would refuse to write the very names we wrote last time.
|
|
517
|
+
// "Ours" now means this owner's — another project's block is exactly the case
|
|
518
|
+
// that was being consumed silently — plus, unless the caller says otherwise,
|
|
519
|
+
// pre-ownership blocks, which the caller is about to replace in place.
|
|
520
|
+
const ours = block =>
|
|
521
|
+
(block.owner === owner && owner !== null) ||
|
|
522
|
+
(block.owner === null && !includeLegacy);
|
|
523
|
+
const outside = stripFamilyBlocks(text, MANAGED_FAMILY, ours);
|
|
524
|
+
|
|
525
|
+
const claimed = [];
|
|
526
|
+
for (const block of familyBlocks(text, MANAGED_FAMILY)) {
|
|
527
|
+
if (ours(block)) continue;
|
|
528
|
+
for (const name of profileNamesIn(block.body)) {
|
|
529
|
+
claimed.push({ name, owner: block.owner });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return names
|
|
534
|
+
.filter(name => sectionPresent(outside, name))
|
|
535
|
+
.map(name => ({
|
|
536
|
+
name,
|
|
537
|
+
owner: claimed.find(entry => entry.name === name)?.owner ?? null,
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Whether a `[profile name]` section header appears in some text.
|
|
543
|
+
* @param {string} text Haystack.
|
|
544
|
+
* @param {string} name Exact profile name.
|
|
545
|
+
* @returns {boolean} Whether the section is defined.
|
|
546
|
+
*/
|
|
547
|
+
function sectionPresent(text, name) {
|
|
548
|
+
return new RegExp(
|
|
549
|
+
`^\\s*\\[profile\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`,
|
|
550
|
+
"m"
|
|
551
|
+
).test(text);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* The `[profile …]` names defined in a chunk of shared-config text.
|
|
556
|
+
* @param {string} text Config text.
|
|
557
|
+
* @returns {string[]} Section names, in order.
|
|
558
|
+
*/
|
|
559
|
+
function profileNamesIn(text) {
|
|
560
|
+
return [...text.matchAll(/^\s*\[profile\s+([^\]\n]+)\]/gm)].map(found =>
|
|
561
|
+
found[1].trim()
|
|
339
562
|
);
|
|
340
563
|
}
|
|
341
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Profile names left in `~/.aws/config` by a build that predated ownership.
|
|
567
|
+
*
|
|
568
|
+
* Reported rather than removed, and that asymmetry with the shell block is the
|
|
569
|
+
* point: a legacy `~/.aws` block carries no record of who wrote it. Its stage
|
|
570
|
+
* names name a stage and no owner, and the account ids in its role ARNs are not
|
|
571
|
+
* tied to any tenant name this process knows. Deleting it could therefore
|
|
572
|
+
* remove another project's working profiles — the exact harm being fixed —
|
|
573
|
+
* so it is named on every run and removed only under an explicit prune.
|
|
574
|
+
* @param {string} dir The `.aws` directory.
|
|
575
|
+
* @param {object} [io] `exists` and `read` seams.
|
|
576
|
+
* @returns {string[]} Unowned profile names still present.
|
|
577
|
+
*/
|
|
578
|
+
export function legacyManagedProfiles(dir, io = {}) {
|
|
579
|
+
const { exists = existsSync, read = readFileSync } = io;
|
|
580
|
+
const file = join(dir, "config");
|
|
581
|
+
if (!exists(file)) return [];
|
|
582
|
+
|
|
583
|
+
const text = String(read(file, "utf8"));
|
|
584
|
+
return familyBlocks(text, MANAGED_FAMILY)
|
|
585
|
+
.filter(block => block.owner === null)
|
|
586
|
+
.flatMap(block => profileNamesIn(block.body));
|
|
587
|
+
}
|
|
588
|
+
|
|
342
589
|
export function installAwsProfiles(bundle, options = {}) {
|
|
343
590
|
const {
|
|
344
591
|
home = process.env.HOME || homedir(),
|
|
592
|
+
owner,
|
|
593
|
+
pruneLegacy = process.env.LISA_SECRETS_PRUNE_LEGACY_PROFILES === "1",
|
|
594
|
+
// The RESOLVE-ONLY window, on by default and closable from both ends. It
|
|
595
|
+
// never affects what is emitted as canonical output.
|
|
596
|
+
// `NO_LEGACY` is how a caller that has migrated takes the isolation before
|
|
597
|
+
// the window closes; `CLAIM_LEGACY` is how one takes the shared bare slot
|
|
598
|
+
// from another project deliberately.
|
|
599
|
+
noResolveOnly = process.env.LISA_SECRETS_NO_LEGACY_PROFILES === "1",
|
|
600
|
+
claimResolveOnly = process.env.LISA_SECRETS_CLAIM_LEGACY_PROFILES === "1",
|
|
345
601
|
mkdir = mkdirSync,
|
|
346
602
|
write = writeAtomic,
|
|
347
603
|
read = readFileSync,
|
|
@@ -349,37 +605,100 @@ export function installAwsProfiles(bundle, options = {}) {
|
|
|
349
605
|
chmod = chmodSync,
|
|
350
606
|
} = options;
|
|
351
607
|
|
|
352
|
-
const rendered = renderAwsProfiles(bundle);
|
|
608
|
+
const rendered = renderAwsProfiles(bundle, owner);
|
|
353
609
|
if (!rendered) return [];
|
|
610
|
+
const scope = assertOwner(owner, "~/.aws");
|
|
354
611
|
|
|
355
612
|
const dir = join(home, ".aws");
|
|
356
613
|
|
|
357
|
-
//
|
|
614
|
+
// Whether a pre-ownership block in this file is THIS project's own past
|
|
615
|
+
// output, or another project's.
|
|
616
|
+
//
|
|
617
|
+
// Leaving one behind is not free. Everywhere else in Lisa the orphan doctrine
|
|
618
|
+
// is absolute — an orphaned block is still read, and the reader cannot tell it
|
|
619
|
+
// from the live one — so a change that started leaving orphans would trade
|
|
620
|
+
// this bug for the one the marker versioning already fixed.
|
|
621
|
+
//
|
|
622
|
+
// The block itself answers the question: the accounts in its role ARNs are
|
|
623
|
+
// this project's if they are the accounts we are about to write. A bundle
|
|
624
|
+
// names its own accounts, so a match is attribution rather than a guess. That
|
|
625
|
+
// makes the ordinary single-tenant upgrade replace its block silently and
|
|
626
|
+
// leave nothing behind, and confines the reported-not-deleted case to a block
|
|
627
|
+
// that provably belongs to a different project.
|
|
628
|
+
const configFile = join(dir, "config");
|
|
629
|
+
const existingConfig = exists(configFile)
|
|
630
|
+
? String(read(configFile, "utf8"))
|
|
631
|
+
: "";
|
|
632
|
+
const claimLegacy =
|
|
633
|
+
pruneLegacy || legacyIsOurs(existingConfig, rendered.config);
|
|
634
|
+
|
|
635
|
+
// Refuse to write a profile name someone else already uses.
|
|
358
636
|
//
|
|
359
637
|
// AWS does not error on a duplicate `[profile x]` — it resolves one and
|
|
360
|
-
// ignores the other. So writing
|
|
361
|
-
//
|
|
362
|
-
// worse than either winning outright. Merging protects
|
|
363
|
-
// being deleted; this protects them from being shadowed.
|
|
638
|
+
// ignores the other. So writing a name next to an operator's existing SSO
|
|
639
|
+
// profile of the same name would silently run some calls as the wrong
|
|
640
|
+
// identity, which is worse than either winning outright. Merging protects
|
|
641
|
+
// their sections from being deleted; this protects them from being shadowed.
|
|
642
|
+
//
|
|
643
|
+
// "Someone else" now includes another Lisa project. Prefixing every name with
|
|
644
|
+
// its owner should make that impossible rather than merely detectable, so
|
|
645
|
+
// this arm is the backstop for the case prefixing cannot prevent — two owners
|
|
646
|
+
// whose prefix and stage happen to reduce to one final name.
|
|
364
647
|
//
|
|
365
648
|
// Deliberately not resolved by renaming theirs: this module writes its own
|
|
366
|
-
// block and nothing else. The
|
|
367
|
-
// re-runs.
|
|
649
|
+
// block and nothing else. The owner of the existing entry renames it
|
|
650
|
+
// (conventionally to `-sso`) and re-runs.
|
|
368
651
|
const collisions = collidingProfiles(dir, rendered.profiles, {
|
|
369
652
|
exists,
|
|
370
653
|
read,
|
|
654
|
+
owner: scope,
|
|
655
|
+
// A legacy block this run is about to replace is not a collision; one it is
|
|
656
|
+
// leaving in place is, because both sections would then exist and AWS would
|
|
657
|
+
// resolve exactly one of them.
|
|
658
|
+
includeLegacy: !claimLegacy,
|
|
371
659
|
});
|
|
372
660
|
if (collisions.length > 0) {
|
|
661
|
+
const quoted = collisions.map(c => `"${c.name}"`).join(", ");
|
|
662
|
+
const holder = collisions[0].owner;
|
|
373
663
|
throw new Error(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
664
|
+
holder === null
|
|
665
|
+
? `~/.aws/config already defines ${quoted} outside any lisa-managed ` +
|
|
666
|
+
`block.\n` +
|
|
667
|
+
`Writing them would create duplicate sections, and AWS resolves ` +
|
|
668
|
+
`only one — some calls would silently use the wrong identity.\n` +
|
|
669
|
+
`Rename the existing entries (for example to ` +
|
|
670
|
+
`"${collisions[0].name}-sso") and run this again.`
|
|
671
|
+
: `~/.aws/config already defines ${quoted} for the Lisa project ` +
|
|
672
|
+
`"${holder}", not "${scope}".\n` +
|
|
673
|
+
`AWS resolves only one section of a duplicated name, so writing ` +
|
|
674
|
+
`these would silently point "${holder}" at ${scope}'s account — ` +
|
|
675
|
+
`and it would authenticate, because the credentials are real.\n` +
|
|
676
|
+
`Rename one project's namespace so the two do not reduce to the ` +
|
|
677
|
+
`same profile name. Nothing was changed.`
|
|
380
678
|
);
|
|
381
679
|
}
|
|
382
680
|
|
|
681
|
+
// Whether the deprecated bare-named twins go in beside the owned names.
|
|
682
|
+
//
|
|
683
|
+
// ALL OR NOTHING. A half-written legacy family is worse than none: a bare
|
|
684
|
+
// `[profile <stage>]` whose `source_profile` names a bare section belonging to
|
|
685
|
+
// someone else is a profile that resolves into another project's account, and
|
|
686
|
+
// it is exactly the failure the owned names exist to remove.
|
|
687
|
+
const legacyHolder = noResolveOnly
|
|
688
|
+
? null
|
|
689
|
+
: resolveOnlyHolder(dir, rendered.resolveOnly, {
|
|
690
|
+
exists,
|
|
691
|
+
read,
|
|
692
|
+
owner: scope,
|
|
693
|
+
includeLegacy: !claimLegacy,
|
|
694
|
+
});
|
|
695
|
+
// `undefined` is the only free state. `null` means a section outside every
|
|
696
|
+
// managed block already holds the name — an operator's, or an external
|
|
697
|
+
// generator's — and shadowing that is the duplicate-section failure this
|
|
698
|
+
// module refuses everywhere else.
|
|
699
|
+
const writeResolveOnly =
|
|
700
|
+
!noResolveOnly && (claimResolveOnly || legacyHolder === undefined);
|
|
701
|
+
|
|
383
702
|
mkdir(dir, { recursive: true, mode: 0o700 });
|
|
384
703
|
chmod(dir, 0o700);
|
|
385
704
|
|
|
@@ -397,18 +716,121 @@ export function installAwsProfiles(bundle, options = {}) {
|
|
|
397
716
|
// what the guard was actually for. Same delimited-block approach as the shell
|
|
398
717
|
// profile, and `#` is a comment in the shared-config format so the markers are
|
|
399
718
|
// inert to every consumer.
|
|
400
|
-
for (const [name, body] of [
|
|
401
|
-
["credentials", rendered.credentials],
|
|
402
|
-
["config", rendered.config],
|
|
719
|
+
for (const [name, body, resolveOnlyBody] of [
|
|
720
|
+
["credentials", rendered.credentials, rendered.resolveOnly.credentials],
|
|
721
|
+
["config", rendered.config, rendered.resolveOnly.config],
|
|
403
722
|
]) {
|
|
404
723
|
const file = join(dir, name);
|
|
405
724
|
const current = exists(file) ? String(read(file, "utf8")) : "";
|
|
406
|
-
|
|
725
|
+
// Both halves live INSIDE this owner's block. That is what keeps the
|
|
726
|
+
// resolve-only names owned rather than anonymous — another project's run
|
|
727
|
+
// leaves them alone instead of replacing them, and this project's next run
|
|
728
|
+
// regenerates the whole block, so nothing accumulates or drifts.
|
|
729
|
+
const merged = writeResolveOnly
|
|
730
|
+
? `${body.trimEnd()}\n\n${resolveOnlyBody}`
|
|
731
|
+
: body;
|
|
732
|
+
write(file, upsertManagedBlock(current, merged, scope, claimLegacy));
|
|
407
733
|
}
|
|
408
734
|
|
|
409
735
|
return rendered.profiles;
|
|
410
736
|
}
|
|
411
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Who already holds any part of the deprecated bare-named family.
|
|
740
|
+
*
|
|
741
|
+
* Both files are consulted, because the family only works whole: the bare
|
|
742
|
+
* `[profile <stage>]` sections in `config` are useless without the bare source
|
|
743
|
+
* profile in `credentials` that they assume from.
|
|
744
|
+
* @param {string} dir The `.aws` directory.
|
|
745
|
+
* @param {{profiles: string[], sourceProfile: string}} resolveOnly The bare half.
|
|
746
|
+
* @param {object} io `exists`, `read`, `owner`, `includeLegacy` seams.
|
|
747
|
+
* @returns {string|null|undefined} The holding project, `null` when held
|
|
748
|
+
* outside any managed block, or `undefined` when nobody holds it.
|
|
749
|
+
*/
|
|
750
|
+
function resolveOnlyHolder(dir, resolveOnly, io) {
|
|
751
|
+
const inConfig = collidingProfiles(dir, resolveOnly.profiles, io);
|
|
752
|
+
if (inConfig.length > 0) return inConfig[0].owner;
|
|
753
|
+
return sourceProfileHolder(dir, resolveOnly.sourceProfile, io);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Who holds a bare source-profile section in `~/.aws/credentials`.
|
|
758
|
+
*
|
|
759
|
+
* Separate from `collidingProfiles` because a credentials section is spelled
|
|
760
|
+
* `[name]`, not `[profile name]`, and reusing the config reader here would
|
|
761
|
+
* silently match nothing — a guard that always passes.
|
|
762
|
+
* @param {string} dir The `.aws` directory.
|
|
763
|
+
* @param {string} name The section name.
|
|
764
|
+
* @param {object} io `exists`, `read`, `owner`, `includeLegacy` seams.
|
|
765
|
+
* @returns {string|null|undefined} As {@link resolveOnlyHolder}.
|
|
766
|
+
*/
|
|
767
|
+
export function sourceProfileHolder(dir, name, io = {}) {
|
|
768
|
+
const {
|
|
769
|
+
exists = existsSync,
|
|
770
|
+
read = readFileSync,
|
|
771
|
+
owner = null,
|
|
772
|
+
includeLegacy = false,
|
|
773
|
+
} = io;
|
|
774
|
+
const file = join(dir, "credentials");
|
|
775
|
+
if (!exists(file)) return undefined;
|
|
776
|
+
|
|
777
|
+
const text = String(read(file, "utf8"));
|
|
778
|
+
const ours = block =>
|
|
779
|
+
(block.owner === owner && owner !== null) ||
|
|
780
|
+
(block.owner === null && !includeLegacy);
|
|
781
|
+
const header = new RegExp(
|
|
782
|
+
`^\\s*\\[${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\]`,
|
|
783
|
+
"m"
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
for (const block of familyBlocks(text, MANAGED_FAMILY)) {
|
|
787
|
+
if (ours(block)) continue;
|
|
788
|
+
if (header.test(block.body)) return block.owner;
|
|
789
|
+
}
|
|
790
|
+
// Outside every managed block: an operator's own section, or one written by a
|
|
791
|
+
// generator that does not go through this module at all.
|
|
792
|
+
return header.test(stripFamilyBlocks(text, MANAGED_FAMILY, () => true))
|
|
793
|
+
? null
|
|
794
|
+
: undefined;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The 12-digit accounts named by the role ARNs in some shared-config text.
|
|
799
|
+
* @param {string} text Config text.
|
|
800
|
+
* @returns {Set<string>} Account ids.
|
|
801
|
+
*/
|
|
802
|
+
function accountsIn(text) {
|
|
803
|
+
return new Set(
|
|
804
|
+
[...text.matchAll(/^\s*role_arn\s*=\s*arn:[^:]*:iam::(\d+):/gm)].map(
|
|
805
|
+
found => found[1]
|
|
806
|
+
)
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Whether every pre-ownership block present is this project's own past output.
|
|
812
|
+
*
|
|
813
|
+
* Conservative on purpose. A legacy block with no role ARN at all cannot be
|
|
814
|
+
* attributed, so it is not claimed; and one account belonging to someone else is
|
|
815
|
+
* enough to leave the whole population alone, because a partial claim would
|
|
816
|
+
* delete a block this process could not read the ownership of.
|
|
817
|
+
* @param {string} current Existing config contents.
|
|
818
|
+
* @param {string} ours The config body about to be written.
|
|
819
|
+
* @returns {boolean} Whether the legacy blocks may be replaced.
|
|
820
|
+
*/
|
|
821
|
+
function legacyIsOurs(current, ours) {
|
|
822
|
+
const legacy = familyBlocks(current, MANAGED_FAMILY).filter(
|
|
823
|
+
block => block.owner === null
|
|
824
|
+
);
|
|
825
|
+
if (legacy.length === 0) return false;
|
|
826
|
+
|
|
827
|
+
const mine = accountsIn(ours);
|
|
828
|
+
return legacy.every(block => {
|
|
829
|
+
const theirs = accountsIn(block.body);
|
|
830
|
+
return theirs.size > 0 && [...theirs].every(account => mine.has(account));
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
412
834
|
export function materialize(cfg = readConfig(), options = {}) {
|
|
413
835
|
// `requested` distinguishes an OPERATOR asking from a flow deciding.
|
|
414
836
|
//
|
|
@@ -447,7 +869,12 @@ export function materialize(cfg = readConfig(), options = {}) {
|
|
|
447
869
|
// AWS_ACCESS_KEY_ID wins — environment variables outrank profile files in the
|
|
448
870
|
// credential chain — and every AWS call fails with InvalidClientTokenId even
|
|
449
871
|
// though the real credential materialized correctly.
|
|
450
|
-
|
|
872
|
+
// The namespace IS the owner. It already scopes `~/.config/<namespace>`, so
|
|
873
|
+
// two repositories of one tenant share and two tenants do not — exactly the
|
|
874
|
+
// sharing model the shared write paths below were missing.
|
|
875
|
+
const owner = assertOwner(cfg.namespace, "the materialized credentials");
|
|
876
|
+
|
|
877
|
+
const derived = deriveAwsEnvironment(selected, owner);
|
|
451
878
|
for (const [name, entry] of derived) selected.set(name, entry);
|
|
452
879
|
|
|
453
880
|
const { dir, valuesFile, notesFile } = materializedPaths(cfg.namespace);
|
|
@@ -467,14 +894,15 @@ export function materialize(cfg = readConfig(), options = {}) {
|
|
|
467
894
|
//
|
|
468
895
|
// So the shell profile sources it. That is what makes "the credential is
|
|
469
896
|
// materialized" and "the credential is usable" the same statement.
|
|
470
|
-
const sourced = installProfileSourcing(valuesFile);
|
|
897
|
+
const sourced = installProfileSourcing(valuesFile, { owner });
|
|
471
898
|
|
|
472
899
|
// The environments live in separate AWS accounts, reached by assuming a role
|
|
473
|
-
// per environment. Without these files `--profile agent-dev` fails
|
|
474
|
-
// "profile not found" and everything silently falls back to the
|
|
475
|
-
// identity, which can assume roles and do nothing else.
|
|
900
|
+
// per environment. Without these files `--profile <owner>-agent-dev` fails
|
|
901
|
+
// with "profile not found" and everything silently falls back to the
|
|
902
|
+
// bootstrap identity, which can assume roles and do nothing else.
|
|
476
903
|
const profiles = installAwsProfiles(
|
|
477
|
-
parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value)
|
|
904
|
+
parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value),
|
|
905
|
+
{ owner }
|
|
478
906
|
);
|
|
479
907
|
|
|
480
908
|
return {
|
|
@@ -483,6 +911,7 @@ export function materialize(cfg = readConfig(), options = {}) {
|
|
|
483
911
|
dir,
|
|
484
912
|
sourced,
|
|
485
913
|
profiles,
|
|
914
|
+
legacy: legacyManagedProfiles(join(process.env.HOME || homedir(), ".aws")),
|
|
486
915
|
};
|
|
487
916
|
}
|
|
488
917
|
|
|
@@ -509,7 +938,8 @@ function main() {
|
|
|
509
938
|
if (process.argv.includes("--aws-profiles-only")) {
|
|
510
939
|
const selected = fetchAll(cfg);
|
|
511
940
|
const bundle = parseBootstrap(selected.get(BOOTSTRAP_KEY)?.value);
|
|
512
|
-
const
|
|
941
|
+
const owner = assertOwner(cfg.namespace, "~/.aws");
|
|
942
|
+
const written = installAwsProfiles(bundle, { owner });
|
|
513
943
|
|
|
514
944
|
if (written.length === 0) {
|
|
515
945
|
console.log(
|
|
@@ -523,9 +953,11 @@ function main() {
|
|
|
523
953
|
);
|
|
524
954
|
console.log(
|
|
525
955
|
` Sourced from a long-lived key pair in ~/.aws/credentials. Existing\n` +
|
|
526
|
-
` sections were preserved — only
|
|
956
|
+
` sections were preserved — only "${owner}"'s block was replaced.\n` +
|
|
527
957
|
` Use them explicitly: aws --profile ${written[0]} ...`
|
|
528
958
|
);
|
|
959
|
+
reportLegacyProfiles();
|
|
960
|
+
reportResolveOnlySlot(cfg.namespace);
|
|
529
961
|
return;
|
|
530
962
|
}
|
|
531
963
|
|
|
@@ -535,7 +967,10 @@ function main() {
|
|
|
535
967
|
// the derived names are exactly the ones that override ambient host
|
|
536
968
|
// credentials, so omitting them hides the most surprising effect of the run
|
|
537
969
|
// and reports a smaller count than the real write produces.
|
|
538
|
-
for (const [name, entry] of deriveAwsEnvironment(
|
|
970
|
+
for (const [name, entry] of deriveAwsEnvironment(
|
|
971
|
+
selected,
|
|
972
|
+
assertOwner(cfg.namespace, "the materialized credentials")
|
|
973
|
+
)) {
|
|
539
974
|
selected.set(name, entry);
|
|
540
975
|
}
|
|
541
976
|
const { dir } = materializedPaths(cfg.namespace);
|
|
@@ -562,6 +997,60 @@ function main() {
|
|
|
562
997
|
`explicit --profile.`
|
|
563
998
|
);
|
|
564
999
|
}
|
|
1000
|
+
reportLegacyProfiles();
|
|
1001
|
+
reportResolveOnlySlot(cfg.namespace);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Say so when the deprecated bare names went to a different project.
|
|
1006
|
+
*
|
|
1007
|
+
* Silence here would be the worst of both worlds. A caller that has not
|
|
1008
|
+
* migrated still names the bare profiles; if another project on this machine
|
|
1009
|
+
* holds them, those names resolve into that project's account and the run would
|
|
1010
|
+
* otherwise report success. Loud-and-wrong is recoverable, silent-and-wrong is
|
|
1011
|
+
* the defect this whole change is about.
|
|
1012
|
+
* @param {string} owner The tenant this run materialized for.
|
|
1013
|
+
*/
|
|
1014
|
+
function reportResolveOnlySlot(owner) {
|
|
1015
|
+
const holder = sourceProfileHolder(
|
|
1016
|
+
join(process.env.HOME || homedir(), ".aws"),
|
|
1017
|
+
RESOLVE_ONLY_LEGACY_SOURCE_PROFILE,
|
|
1018
|
+
{ owner }
|
|
1019
|
+
);
|
|
1020
|
+
if (holder === undefined || holder === owner) return;
|
|
1021
|
+
console.log(
|
|
1022
|
+
` The deprecated bare profile names were NOT written for "${owner}".\n` +
|
|
1023
|
+
` ${holder === null ? "A section outside any lisa-managed block" : `The project "${holder}"`} ` +
|
|
1024
|
+
`already holds "${RESOLVE_ONLY_LEGACY_SOURCE_PROFILE}".\n` +
|
|
1025
|
+
` They are one shared slot, so only one project on a machine can have ` +
|
|
1026
|
+
`them.\n Use the owned names — aws --profile ${owner}-<stage> — or ` +
|
|
1027
|
+
`re-run with\n LISA_SECRETS_CLAIM_LEGACY_PROFILES=1 to take the bare ` +
|
|
1028
|
+
`names deliberately.`
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Name any pre-ownership profiles still sitting in `~/.aws/config`.
|
|
1034
|
+
*
|
|
1035
|
+
* Reported on every run rather than once, because the population never drains
|
|
1036
|
+
* on its own and nothing else ever revisits this file — there is no apply, diff
|
|
1037
|
+
* or review that would surface it. Never deleted here: an unowned profile may
|
|
1038
|
+
* belong to another project on this machine, and removing it would be the same
|
|
1039
|
+
* silent consumption in the other direction.
|
|
1040
|
+
*/
|
|
1041
|
+
function reportLegacyProfiles() {
|
|
1042
|
+
const stale = legacyManagedProfiles(
|
|
1043
|
+
join(process.env.HOME || homedir(), ".aws")
|
|
1044
|
+
);
|
|
1045
|
+
if (stale.length === 0) return;
|
|
1046
|
+
console.log(
|
|
1047
|
+
` ~/.aws/config still holds ${stale.length} profile(s) written before ` +
|
|
1048
|
+
`Lisa\n recorded which project a profile belongs to: ` +
|
|
1049
|
+
`${stale.join(", ")}.\n` +
|
|
1050
|
+
` They name a stage but no owner, so on a machine serving more than one ` +
|
|
1051
|
+
`\n project they can resolve to another project's account. Review them, ` +
|
|
1052
|
+
`or\n re-run with LISA_SECRETS_PRUNE_LEGACY_PROFILES=1 to remove them.`
|
|
1053
|
+
);
|
|
565
1054
|
}
|
|
566
1055
|
|
|
567
1056
|
/**
|