@empire-builder-kit/nx 1.0.0-rc.40 → 1.0.0-rc.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -10
- package/package.json +3 -3
- package/src/blueprints/external-sandbox.d.ts +1 -1
- package/src/cli/deploy-runtime.d.ts +25 -1
- package/src/cli/deploy-runtime.js +120 -4
- package/src/cli/dev-command.d.ts +2 -0
- package/src/cli/dev-command.js +8 -0
- package/src/cli/main.js +20 -6
- package/src/cli/personal-secret-runtime.d.ts +12 -0
- package/src/cli/personal-secret-runtime.js +18 -3
- package/src/cli/secret-terminal.js +1 -1
- package/src/container-images.d.ts +1 -1
- package/src/container-images.js +1 -1
- package/src/developer-agent/operation-registry.d.ts +10 -0
- package/src/developer-agent/operation-registry.js +60 -13
- package/src/developer-agent/planning.d.ts +8 -1
- package/src/developer-agent/planning.js +23 -3
- package/src/executors/local-deploy/local-approval.js +1 -1
- package/src/executors/sst-lifecycle/governed-native-operation.js +3 -2
- package/src/executors/sst-lifecycle/run-sst-command.d.ts +6 -0
- package/src/executors/sst-lifecycle/run-sst-command.js +3 -33
- package/src/foundation/cost-estimate.d.ts +170 -0
- package/src/foundation/cost-estimate.js +683 -0
- package/src/generators/preset/tool-versions.d.ts +0 -2
- package/src/generators/preset/tool-versions.js +1 -3
- package/src/generators/preset/workspace-files-adopter-skills.js +39 -18
- package/src/generators/preset/workspace-files-developer-agent.js +83 -27
- package/src/generators/preset/workspace-files-shared-operation-claims.d.ts +10 -0
- package/src/generators/preset/workspace-files-shared-operation-claims.js +59 -0
- package/src/generators/preset/workspace-files-stage-authority.js +13 -2
- package/src/generators/preset/workspace-files-thin-deploy.d.ts +10 -0
- package/src/generators/preset/workspace-files-thin-deploy.js +54 -20
- package/src/generators/preset/workspace-files-thin-docs.js +32 -14
- package/src/generators/preset/workspace-files.js +28 -8
- package/src/generators/slice/built-in-plan-api-contract-files.js +7 -4
- package/src/generators/slice/built-in-plan-authorization-files.js +10 -5
- package/src/generators/slice/built-in-plan-authorization-infrastructure-files.js +933 -249
- package/src/generators/slice/built-in-plan-data-files.js +17 -8
- package/src/generators/slice/built-in-plan-database-retirement-rehearsal-files.js +5 -1
- package/src/generators/slice/built-in-plan-frontend-files.js +7 -0
- package/src/generators/slice/built-in-plan-infrastructure-files.js +0 -1
- package/src/generators/slice/built-in-plan-infrastructure-safety-files.js +8 -3
- package/src/generators/slice/built-in-plan-metadata-files.js +2 -1
- package/src/generators/slice/built-in-plan-reliability-files.js +4 -1
- package/src/generators/slice/built-in-plan-testing-persistence-event-files.d.ts +7 -0
- package/src/generators/slice/built-in-plan-testing-persistence-event-files.js +487 -0
- package/src/generators/slice/built-in-plan-testing-persistence-files.js +2 -0
- package/src/generators/slice/built-in-plan.js +3 -1
- package/src/generators/workload/frozen-container.d.ts +1 -1
- package/src/git-policy/github-bootstrap.d.ts +9 -0
- package/src/git-policy/github-bootstrap.js +16 -2
- package/src/local-dev/fleet-inventory.js +18 -10
- package/src/local-dev/fleet-launcher.js +2 -2
- package/src/secrets/personal-command.d.ts +18 -2
- package/src/secrets/personal-command.js +66 -12
- package/src/secrets/verification.js +6 -1
|
@@ -1,252 +1,896 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.renderStandardSliceAuthorizationInfrastructureFiles = renderStandardSliceAuthorizationInfrastructureFiles;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Managed Node.js CloudFormation custom-resource handler. The handler string
|
|
6
|
+
* 'authorization/machine-client-provisioner.handler' and the custom-resource
|
|
7
|
+
* contract (physical id, Data keys, secret document) are unchanged from the
|
|
8
|
+
* retired Python provisioner; only the two owner-approved fixes differ:
|
|
9
|
+
* rotation refusals precede any Cognito create, and Delete acts only on the
|
|
10
|
+
* physical id this provisioner returns.
|
|
11
|
+
*/
|
|
12
|
+
function machineClientProvisionerSource(name) {
|
|
13
|
+
return `import { request } from 'node:https';
|
|
14
|
+
import {
|
|
15
|
+
CognitoIdentityProviderClient,
|
|
16
|
+
CreateUserPoolClientCommand,
|
|
17
|
+
DeleteUserPoolClientCommand,
|
|
18
|
+
DescribeUserPoolClientCommand,
|
|
19
|
+
ListUserPoolClientsCommand,
|
|
20
|
+
} from '@aws-sdk/client-cognito-identity-provider';
|
|
21
|
+
import {
|
|
22
|
+
GetSecretValueCommand,
|
|
23
|
+
PutSecretValueCommand,
|
|
24
|
+
SecretsManagerClient,
|
|
25
|
+
UpdateSecretVersionStageCommand,
|
|
26
|
+
} from '@aws-sdk/client-secrets-manager';
|
|
27
|
+
import { createLogger, type Logger } from '@empire-builder-kit/runtime/logging';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* AWS-internal CloudFormation custom resource for the Slice's confidential
|
|
31
|
+
* Cognito machine client. The client secret moves only from Cognito into the
|
|
32
|
+
* generated Secrets Manager container; it never reaches a response, a log
|
|
33
|
+
* record, or an error reason.
|
|
34
|
+
*/
|
|
35
|
+
export interface MachineClientProperties {
|
|
36
|
+
ClientName: string;
|
|
37
|
+
CredentialGeneration: string;
|
|
38
|
+
ResourceServerIdentifier: string;
|
|
39
|
+
RetirePreviousGeneration: string;
|
|
40
|
+
Scopes: string[];
|
|
41
|
+
SecretArn: string;
|
|
42
|
+
UserPoolId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MachineClientProvisionerEvent {
|
|
46
|
+
LogicalResourceId: string;
|
|
47
|
+
PhysicalResourceId?: string;
|
|
48
|
+
RequestId: string;
|
|
49
|
+
RequestType: string;
|
|
50
|
+
ResourceProperties: MachineClientProperties;
|
|
51
|
+
ResponseURL: string;
|
|
52
|
+
StackId: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MachineClientProvisionerDependencies {
|
|
56
|
+
cognito: { send(command: any): Promise<any> };
|
|
57
|
+
logger: Logger;
|
|
58
|
+
/** Resolves only after CloudFormation has accepted the response. */
|
|
59
|
+
respond(responseUrl: string, body: string): Promise<void>;
|
|
60
|
+
secrets: { send(command: any): Promise<any> };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface CredentialVersion {
|
|
64
|
+
document: Record<string, unknown>;
|
|
65
|
+
versionId: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Reasons keep the exact tokens CloudFormation showed before the Node.js
|
|
69
|
+
// port. Bundles are minified, so names are literals, never class names.
|
|
70
|
+
type ProvisioningErrorName =
|
|
71
|
+
| 'AttributeError'
|
|
72
|
+
| 'HTTPError'
|
|
73
|
+
| 'JSONDecodeError'
|
|
74
|
+
| 'KeyError'
|
|
75
|
+
| 'TypeError'
|
|
76
|
+
| 'URLError'
|
|
77
|
+
| 'ValueError';
|
|
78
|
+
|
|
79
|
+
const PROVISIONING_ERROR_NAMES = new Set<string>([
|
|
80
|
+
'AttributeError',
|
|
81
|
+
'HTTPError',
|
|
82
|
+
'JSONDecodeError',
|
|
83
|
+
'KeyError',
|
|
84
|
+
'TypeError',
|
|
85
|
+
'URLError',
|
|
86
|
+
'ValueError',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
class ProvisioningError extends Error {
|
|
90
|
+
constructor(name: ProvisioningErrorName, message: string) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = name;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refuse(message: string): ProvisioningError {
|
|
97
|
+
return new ProvisioningError('ValueError', message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function errorField(error: unknown, field: '$metadata' | 'name'): unknown {
|
|
101
|
+
return typeof error === 'object' && error !== null
|
|
102
|
+
? (error as Record<string, unknown>)[field]
|
|
103
|
+
: undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reasonFor(error: unknown): string {
|
|
107
|
+
const name = errorField(error, 'name');
|
|
108
|
+
if (typeof name === 'string' && PROVISIONING_ERROR_NAMES.has(name)) {
|
|
109
|
+
return name;
|
|
110
|
+
}
|
|
111
|
+
const metadata = errorField(error, '$metadata');
|
|
112
|
+
if (typeof metadata === 'object' && metadata !== null) return 'ClientError';
|
|
113
|
+
return typeof name === 'string' && /^[A-Za-z][A-Za-z0-9]{0,127}$/.test(name)
|
|
114
|
+
? name
|
|
115
|
+
: 'Error';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isNamed(error: unknown, name: string): boolean {
|
|
119
|
+
return errorField(error, 'name') === name;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** JSON truthiness exactly as the original provisioner evaluated it. */
|
|
123
|
+
function present(value: unknown): boolean {
|
|
124
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
125
|
+
if (typeof value === 'object' && value !== null) {
|
|
126
|
+
return Object.keys(value).length > 0;
|
|
127
|
+
}
|
|
128
|
+
return Boolean(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function required(record: Record<string, unknown>, key: string): unknown {
|
|
132
|
+
if (!(key in record)) throw new ProvisioningError('KeyError', key);
|
|
133
|
+
return record[key];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function slotOf(value: unknown): string {
|
|
137
|
+
if (typeof value !== 'string') {
|
|
138
|
+
throw new ProvisioningError(
|
|
139
|
+
'AttributeError',
|
|
140
|
+
'Machine credential generation is not text.',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return value.split('-', 1)[0];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseCredentialDocument(value: unknown): Record<string, unknown> {
|
|
147
|
+
let document: unknown;
|
|
148
|
+
try {
|
|
149
|
+
document = JSON.parse(value as string);
|
|
150
|
+
} catch {
|
|
151
|
+
throw new ProvisioningError(
|
|
152
|
+
'JSONDecodeError',
|
|
153
|
+
'Machine credential document is not JSON.',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
typeof document !== 'object' ||
|
|
158
|
+
document === null ||
|
|
159
|
+
Array.isArray(document)
|
|
160
|
+
) {
|
|
161
|
+
throw new ProvisioningError(
|
|
162
|
+
'TypeError',
|
|
163
|
+
'Machine credential document is not a JSON object.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return document as Record<string, unknown>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function logDimension(value: string | undefined): string {
|
|
170
|
+
const normalized = (value ?? '').trim();
|
|
171
|
+
return normalized.length > 0 &&
|
|
172
|
+
normalized.length <= 128 &&
|
|
173
|
+
[...normalized].every((character) => {
|
|
174
|
+
const code = character.charCodeAt(0);
|
|
175
|
+
return code > 31 && code !== 127;
|
|
176
|
+
})
|
|
177
|
+
? normalized
|
|
178
|
+
: 'unknown';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createMachineClientProvisioner(
|
|
182
|
+
dependencies: MachineClientProvisionerDependencies,
|
|
183
|
+
): (event: MachineClientProvisionerEvent) => Promise<void> {
|
|
184
|
+
const { cognito, logger, respond, secrets } = dependencies;
|
|
185
|
+
|
|
186
|
+
async function readVersion(
|
|
187
|
+
secretArn: string,
|
|
188
|
+
stage: 'AWSCURRENT' | 'AWSPREVIOUS',
|
|
189
|
+
): Promise<CredentialVersion | undefined> {
|
|
190
|
+
let value: Record<string, unknown>;
|
|
191
|
+
try {
|
|
192
|
+
value = await secrets.send(
|
|
193
|
+
new GetSecretValueCommand({ SecretId: secretArn, VersionStage: stage }),
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (
|
|
197
|
+
isNamed(error, 'ResourceNotFoundException') ||
|
|
198
|
+
isNamed(error, 'InvalidRequestException')
|
|
199
|
+
) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
const document = parseCredentialDocument(required(value, 'SecretString'));
|
|
205
|
+
return {
|
|
206
|
+
document,
|
|
207
|
+
versionId: required(value, 'VersionId') as string,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function findClient(
|
|
212
|
+
userPoolId: string,
|
|
213
|
+
clientName: string,
|
|
214
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
215
|
+
let nextToken: string | undefined;
|
|
216
|
+
do {
|
|
217
|
+
const page = await cognito.send(
|
|
218
|
+
new ListUserPoolClientsCommand({
|
|
219
|
+
UserPoolId: userPoolId,
|
|
220
|
+
MaxResults: 60,
|
|
221
|
+
...(nextToken ? { NextToken: nextToken } : {}),
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
for (const summary of page.UserPoolClients ?? []) {
|
|
225
|
+
if (summary.ClientName === clientName) {
|
|
226
|
+
const described = await cognito.send(
|
|
227
|
+
new DescribeUserPoolClientCommand({
|
|
228
|
+
UserPoolId: userPoolId,
|
|
229
|
+
ClientId: required(summary, 'ClientId') as string,
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
return required(described, 'UserPoolClient') as Record<
|
|
233
|
+
string,
|
|
234
|
+
unknown
|
|
235
|
+
>;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
nextToken = page.NextToken;
|
|
239
|
+
} while (nextToken);
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function ensureClient(
|
|
244
|
+
properties: MachineClientProperties,
|
|
245
|
+
): Promise<Record<string, unknown>> {
|
|
246
|
+
const client = await findClient(
|
|
247
|
+
properties.UserPoolId,
|
|
248
|
+
properties.ClientName,
|
|
249
|
+
);
|
|
250
|
+
if (client && present(client)) return client;
|
|
251
|
+
const created = await cognito.send(
|
|
252
|
+
new CreateUserPoolClientCommand({
|
|
253
|
+
UserPoolId: properties.UserPoolId,
|
|
254
|
+
ClientName: properties.ClientName,
|
|
255
|
+
GenerateSecret: true,
|
|
256
|
+
AllowedOAuthFlows: ['client_credentials'],
|
|
257
|
+
AllowedOAuthFlowsUserPoolClient: true,
|
|
258
|
+
AllowedOAuthScopes: properties.Scopes,
|
|
259
|
+
AccessTokenValidity: 15,
|
|
260
|
+
TokenValidityUnits: { AccessToken: 'minutes' },
|
|
261
|
+
EnableTokenRevocation: true,
|
|
262
|
+
PreventUserExistenceErrors: 'ENABLED',
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
return required(created, 'UserPoolClient') as Record<string, unknown>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function deleteClient(
|
|
269
|
+
userPoolId: string,
|
|
270
|
+
clientId: unknown,
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
try {
|
|
273
|
+
await cognito.send(
|
|
274
|
+
new DeleteUserPoolClientCommand({
|
|
275
|
+
UserPoolId: userPoolId,
|
|
276
|
+
ClientId: clientId as string,
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (!isNamed(error, 'ResourceNotFoundException')) throw error;
|
|
14
281
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function record(
|
|
285
|
+
event: MachineClientProvisionerEvent,
|
|
286
|
+
outcome: 'failure' | 'success',
|
|
287
|
+
errorCode?: string,
|
|
288
|
+
skipped?: string,
|
|
289
|
+
): void {
|
|
290
|
+
const attributes: Record<string, string> = {
|
|
291
|
+
requestType: String(event.RequestType ?? 'unknown').slice(0, 32),
|
|
292
|
+
};
|
|
293
|
+
if (errorCode) attributes.errorCode = errorCode.slice(0, 128);
|
|
294
|
+
if (skipped) attributes.skipped = skipped;
|
|
295
|
+
const input = {
|
|
296
|
+
attributes,
|
|
297
|
+
context: {
|
|
298
|
+
requestId: String(event.RequestId ?? 'unknown').slice(0, 128),
|
|
299
|
+
},
|
|
300
|
+
outcome,
|
|
301
|
+
};
|
|
302
|
+
if (outcome === 'success') {
|
|
303
|
+
logger.info('Machine client provisioning completed.', input);
|
|
304
|
+
} else {
|
|
305
|
+
logger.error('Machine client provisioning failed.', input);
|
|
33
306
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
delete_client(properties["UserPoolId"],document["clientId"])
|
|
125
|
-
emit(
|
|
126
|
-
event,
|
|
127
|
-
"INFO",
|
|
128
|
-
"success",
|
|
129
|
-
"Machine client provisioning completed.",
|
|
130
|
-
)
|
|
131
|
-
respond(event,"SUCCESS",physical)
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
generation=properties["CredentialGeneration"]
|
|
135
|
-
retire=properties["RetirePreviousGeneration"]=="true"
|
|
136
|
-
if retire and not current:
|
|
137
|
-
raise ValueError("No current machine credential generation exists.")
|
|
138
|
-
client=ensure_client(properties)
|
|
139
|
-
client_id=client["ClientId"]
|
|
140
|
-
rollback=(
|
|
141
|
-
current
|
|
142
|
-
and previous
|
|
143
|
-
and current.get("generation")!=generation
|
|
144
|
-
and previous.get("generation")==generation
|
|
145
|
-
)
|
|
146
|
-
if rollback:
|
|
147
|
-
if retire:
|
|
148
|
-
raise ValueError("Restore and prove the rollback generation before retiring the displaced generation.")
|
|
149
|
-
if previous.get("clientId")!=client_id:
|
|
150
|
-
raise ValueError("Rollback generation does not match its existing Cognito client.")
|
|
151
|
-
secrets.update_secret_version_stage(
|
|
152
|
-
SecretId=properties["SecretArn"],
|
|
153
|
-
VersionStage="AWSCURRENT",
|
|
154
|
-
MoveToVersionId=previous["_versionId"],
|
|
155
|
-
RemoveFromVersionId=current["_versionId"],
|
|
156
|
-
)
|
|
157
|
-
current,previous=previous,current
|
|
158
|
-
elif current and current.get("generation")!=generation:
|
|
159
|
-
if previous:
|
|
160
|
-
raise ValueError("Retire the previous generation before beginning another rotation.")
|
|
161
|
-
old_slot=current["generation"].split("-",1)[0]
|
|
162
|
-
new_slot=generation.split("-",1)[0]
|
|
163
|
-
if old_slot==new_slot:
|
|
164
|
-
raise ValueError("Credential generation must alternate blue and green slots.")
|
|
165
|
-
if retire:
|
|
166
|
-
raise ValueError("Create and prove a new generation before retiring the previous one.")
|
|
167
|
-
|
|
168
|
-
if not rollback and (
|
|
169
|
-
not current
|
|
170
|
-
or current.get("generation")!=generation
|
|
171
|
-
or current.get("clientId")!=client_id
|
|
172
|
-
):
|
|
173
|
-
secrets.put_secret_value(
|
|
174
|
-
SecretId=properties["SecretArn"],
|
|
175
|
-
SecretString=json.dumps({
|
|
176
|
-
"clientId":client_id,
|
|
177
|
-
"clientSecret":client["ClientSecret"],
|
|
178
|
-
"generation":generation,
|
|
179
|
-
},separators=(",",":")),
|
|
180
|
-
)
|
|
181
|
-
previous=current
|
|
182
|
-
|
|
183
|
-
if retire:
|
|
184
|
-
if not previous:
|
|
185
|
-
raise ValueError("No previous machine credential generation exists.")
|
|
186
|
-
previous_id=previous.get("clientId")
|
|
187
|
-
if previous_id and previous_id!=client_id:
|
|
188
|
-
delete_client(properties["UserPoolId"],previous_id)
|
|
189
|
-
version_id=previous.get("_versionId")
|
|
190
|
-
if version_id:
|
|
191
|
-
secrets.update_secret_version_stage(
|
|
192
|
-
SecretId=properties["SecretArn"],
|
|
193
|
-
VersionStage="AWSPREVIOUS",
|
|
194
|
-
RemoveFromVersionId=version_id,
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
accepted_client_ids=[client_id]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function respondTo(
|
|
310
|
+
event: MachineClientProvisionerEvent,
|
|
311
|
+
status: 'FAILED' | 'SUCCESS',
|
|
312
|
+
physicalResourceId: string,
|
|
313
|
+
data?: Record<string, string>,
|
|
314
|
+
reason?: string,
|
|
315
|
+
): Promise<void> {
|
|
316
|
+
const body = JSON.stringify({
|
|
317
|
+
Status: status,
|
|
318
|
+
Reason: reason || status,
|
|
319
|
+
PhysicalResourceId: physicalResourceId,
|
|
320
|
+
StackId: event.StackId,
|
|
321
|
+
RequestId: event.RequestId,
|
|
322
|
+
LogicalResourceId: event.LogicalResourceId,
|
|
323
|
+
NoEcho: false,
|
|
324
|
+
Data: data ?? {},
|
|
325
|
+
});
|
|
326
|
+
await respond(event.ResponseURL, body);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return async function provisionMachineClient(event) {
|
|
330
|
+
const properties = event.ResourceProperties;
|
|
331
|
+
const physical =
|
|
332
|
+
'ebk-machine-client-' + properties.ResourceServerIdentifier;
|
|
333
|
+
// Delete acts only on the resource this provisioner returned. Any other
|
|
334
|
+
// physical id is acknowledged without reading or deleting a credential.
|
|
335
|
+
const foreignDelete =
|
|
336
|
+
event.RequestType === 'Delete' && event.PhysicalResourceId !== physical;
|
|
337
|
+
const responsePhysicalId =
|
|
338
|
+
foreignDelete && event.PhysicalResourceId
|
|
339
|
+
? event.PhysicalResourceId
|
|
340
|
+
: physical;
|
|
341
|
+
try {
|
|
342
|
+
if (foreignDelete) {
|
|
343
|
+
record(event, 'success', undefined, 'foreign-physical-resource-id');
|
|
344
|
+
await respondTo(event, 'SUCCESS', responsePhysicalId);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const property = (key: keyof MachineClientProperties) =>
|
|
348
|
+
required(properties as unknown as Record<string, unknown>, key);
|
|
349
|
+
let current = await readVersion(
|
|
350
|
+
property('SecretArn') as string,
|
|
351
|
+
'AWSCURRENT',
|
|
352
|
+
);
|
|
353
|
+
let previous = await readVersion(
|
|
354
|
+
property('SecretArn') as string,
|
|
355
|
+
'AWSPREVIOUS',
|
|
356
|
+
);
|
|
357
|
+
if (event.RequestType === 'Delete') {
|
|
358
|
+
for (const version of [current, previous]) {
|
|
359
|
+
if (version && present(version.document.clientId)) {
|
|
360
|
+
await deleteClient(
|
|
361
|
+
properties.UserPoolId,
|
|
362
|
+
version.document.clientId,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
record(event, 'success');
|
|
367
|
+
await respondTo(event, 'SUCCESS', responsePhysicalId);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const generation = property('CredentialGeneration');
|
|
372
|
+
const retire = property('RetirePreviousGeneration') === 'true';
|
|
373
|
+
if (retire && !current) {
|
|
374
|
+
throw refuse('No current machine credential generation exists.');
|
|
375
|
+
}
|
|
376
|
+
const rollbackFrom =
|
|
377
|
+
current &&
|
|
378
|
+
previous &&
|
|
379
|
+
current.document.generation !== generation &&
|
|
380
|
+
previous.document.generation === generation
|
|
381
|
+
? { current, previous }
|
|
382
|
+
: undefined;
|
|
383
|
+
// Every rotation refusal precedes the first Cognito call, so a refused
|
|
384
|
+
// deployment never creates an orphaned client.
|
|
385
|
+
if (rollbackFrom) {
|
|
386
|
+
if (retire) {
|
|
387
|
+
throw refuse(
|
|
388
|
+
'Restore and prove the rollback generation before retiring the displaced generation.',
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
} else if (current && current.document.generation !== generation) {
|
|
392
|
+
if (previous) {
|
|
393
|
+
throw refuse(
|
|
394
|
+
'Retire the previous generation before beginning another rotation.',
|
|
395
|
+
);
|
|
396
|
+
}
|
|
198
397
|
if (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
398
|
+
slotOf(required(current.document, 'generation')) ===
|
|
399
|
+
slotOf(generation)
|
|
400
|
+
) {
|
|
401
|
+
throw refuse(
|
|
402
|
+
'Credential generation must alternate blue and green slots.',
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (retire) {
|
|
406
|
+
throw refuse(
|
|
407
|
+
'Create and prove a new generation before retiring the previous one.',
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A rollback reuses the displaced client and never creates one.
|
|
413
|
+
const client = rollbackFrom
|
|
414
|
+
? await findClient(properties.UserPoolId, properties.ClientName)
|
|
415
|
+
: await ensureClient(properties);
|
|
416
|
+
const clientId = client ? required(client, 'ClientId') : undefined;
|
|
417
|
+
if (rollbackFrom) {
|
|
418
|
+
if (!client || rollbackFrom.previous.document.clientId !== clientId) {
|
|
419
|
+
throw refuse(
|
|
420
|
+
'Rollback generation does not match its existing Cognito client.',
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
await secrets.send(
|
|
424
|
+
new UpdateSecretVersionStageCommand({
|
|
425
|
+
SecretId: properties.SecretArn,
|
|
426
|
+
VersionStage: 'AWSCURRENT',
|
|
427
|
+
MoveToVersionId: rollbackFrom.previous.versionId,
|
|
428
|
+
RemoveFromVersionId: rollbackFrom.current.versionId,
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
current = rollbackFrom.previous;
|
|
432
|
+
previous = rollbackFrom.current;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (
|
|
436
|
+
!rollbackFrom &&
|
|
437
|
+
(!current ||
|
|
438
|
+
current.document.generation !== generation ||
|
|
439
|
+
current.document.clientId !== clientId)
|
|
440
|
+
) {
|
|
441
|
+
await secrets.send(
|
|
442
|
+
new PutSecretValueCommand({
|
|
443
|
+
SecretId: properties.SecretArn,
|
|
444
|
+
SecretString: JSON.stringify({
|
|
445
|
+
clientId,
|
|
446
|
+
clientSecret: required(client ?? {}, 'ClientSecret'),
|
|
447
|
+
generation,
|
|
448
|
+
}),
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
previous = current;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (retire) {
|
|
455
|
+
if (!previous) {
|
|
456
|
+
throw refuse('No previous machine credential generation exists.');
|
|
457
|
+
}
|
|
458
|
+
const previousId = previous.document.clientId;
|
|
459
|
+
if (present(previousId) && previousId !== clientId) {
|
|
460
|
+
await deleteClient(properties.UserPoolId, previousId);
|
|
461
|
+
}
|
|
462
|
+
if (present(previous.versionId)) {
|
|
463
|
+
await secrets.send(
|
|
464
|
+
new UpdateSecretVersionStageCommand({
|
|
465
|
+
SecretId: properties.SecretArn,
|
|
466
|
+
VersionStage: 'AWSPREVIOUS',
|
|
467
|
+
RemoveFromVersionId: previous.versionId,
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const acceptedClientIds: unknown[] = [clientId];
|
|
474
|
+
if (
|
|
475
|
+
!retire &&
|
|
476
|
+
previous &&
|
|
477
|
+
present(previous.document.clientId) &&
|
|
478
|
+
previous.document.clientId !== clientId
|
|
479
|
+
) {
|
|
480
|
+
acceptedClientIds.push(previous.document.clientId);
|
|
481
|
+
}
|
|
482
|
+
record(event, 'success');
|
|
483
|
+
await respondTo(event, 'SUCCESS', responsePhysicalId, {
|
|
484
|
+
AcceptedClientIdsJson: JSON.stringify(acceptedClientIds),
|
|
485
|
+
ClientId: clientId as string,
|
|
486
|
+
});
|
|
487
|
+
} catch (error) {
|
|
488
|
+
const reason = reasonFor(error);
|
|
489
|
+
record(event, 'failure', reason);
|
|
490
|
+
await respondTo(event, 'FAILED', responsePhysicalId, undefined, reason);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** PUTs the presigned CloudFormation response and waits for acceptance. */
|
|
496
|
+
export function putCloudFormationResponse(
|
|
497
|
+
responseUrl: string,
|
|
498
|
+
body: string,
|
|
499
|
+
): Promise<void> {
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
const outgoing = request(
|
|
502
|
+
responseUrl,
|
|
503
|
+
{
|
|
504
|
+
// Like urllib, never pool the socket: a connection kept across a
|
|
505
|
+
// frozen Lambda environment can be dead when the next request thaws.
|
|
506
|
+
headers: {
|
|
507
|
+
connection: 'close',
|
|
508
|
+
'content-length': String(Buffer.byteLength(body)),
|
|
509
|
+
'content-type': '',
|
|
510
|
+
},
|
|
511
|
+
method: 'PUT',
|
|
512
|
+
},
|
|
513
|
+
(response) => {
|
|
514
|
+
response.on('error', () =>
|
|
515
|
+
reject(new ProvisioningError('URLError', 'Response stream failed.')),
|
|
516
|
+
);
|
|
517
|
+
response.on('end', () => {
|
|
518
|
+
const status = response.statusCode ?? 0;
|
|
519
|
+
if (status >= 200 && status < 300) resolve();
|
|
520
|
+
else {
|
|
521
|
+
reject(
|
|
522
|
+
new ProvisioningError(
|
|
523
|
+
'HTTPError',
|
|
524
|
+
'CloudFormation rejected the response with HTTP ' +
|
|
525
|
+
status +
|
|
526
|
+
'.',
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
response.resume();
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
outgoing.on('error', () =>
|
|
535
|
+
reject(new ProvisioningError('URLError', 'Response delivery failed.')),
|
|
536
|
+
);
|
|
537
|
+
outgoing.end(body);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const logger = createLogger({
|
|
542
|
+
component: 'machine-client-provisioner',
|
|
543
|
+
feature: 'authorization',
|
|
544
|
+
operation: 'machine-client.provision',
|
|
545
|
+
slice: '${name}',
|
|
546
|
+
stage: logDimension(process.env.EBK_STAGE),
|
|
547
|
+
workspace: logDimension(process.env.EBK_WORKSPACE),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
export const handler = createMachineClientProvisioner({
|
|
551
|
+
cognito: new CognitoIdentityProviderClient({}),
|
|
552
|
+
logger,
|
|
553
|
+
respond: putCloudFormationResponse,
|
|
554
|
+
secrets: new SecretsManagerClient({}),
|
|
555
|
+
});
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
function machineClientProvisionerSpec(name) {
|
|
559
|
+
return `import {
|
|
560
|
+
CreateUserPoolClientCommand,
|
|
561
|
+
DeleteUserPoolClientCommand,
|
|
562
|
+
DescribeUserPoolClientCommand,
|
|
563
|
+
ListUserPoolClientsCommand,
|
|
564
|
+
} from '@aws-sdk/client-cognito-identity-provider';
|
|
565
|
+
import {
|
|
566
|
+
GetSecretValueCommand,
|
|
567
|
+
PutSecretValueCommand,
|
|
568
|
+
UpdateSecretVersionStageCommand,
|
|
569
|
+
} from '@aws-sdk/client-secrets-manager';
|
|
570
|
+
import {
|
|
571
|
+
createLogger,
|
|
572
|
+
type LogEntry,
|
|
573
|
+
} from '@empire-builder-kit/runtime/logging';
|
|
574
|
+
import { describe, expect, it } from 'vitest';
|
|
575
|
+
import {
|
|
576
|
+
createMachineClientProvisioner,
|
|
577
|
+
type MachineClientProvisionerEvent,
|
|
578
|
+
} from './machine-client-provisioner.js';
|
|
579
|
+
|
|
580
|
+
const POOL = 'pool-1';
|
|
581
|
+
const SECRET = 'arn:aws:secretsmanager:region:account:secret:machine-client';
|
|
582
|
+
const SLICE = '${name}';
|
|
583
|
+
const PHYSICAL = 'ebk-machine-client-' + SLICE + '-development';
|
|
584
|
+
|
|
585
|
+
class MemoryAws {
|
|
586
|
+
readonly calls: string[] = [];
|
|
587
|
+
readonly clients = new Map<string, { name: string; secret: string }>();
|
|
588
|
+
readonly logs: LogEntry[] = [];
|
|
589
|
+
readonly responses: Array<Record<string, unknown>> = [];
|
|
590
|
+
readonly versions: Array<{ id: string; stages: string[]; value: string }> =
|
|
591
|
+
[];
|
|
592
|
+
private created = 0;
|
|
593
|
+
|
|
594
|
+
readonly cognito = {
|
|
595
|
+
send: async (command: unknown) => {
|
|
596
|
+
if (command instanceof ListUserPoolClientsCommand) {
|
|
597
|
+
this.calls.push('ListUserPoolClients');
|
|
598
|
+
return {
|
|
599
|
+
UserPoolClients: [...this.clients].map(([id, client]) => ({
|
|
600
|
+
ClientId: id,
|
|
601
|
+
ClientName: client.name,
|
|
602
|
+
})),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
if (command instanceof DescribeUserPoolClientCommand) {
|
|
606
|
+
this.calls.push('DescribeUserPoolClient');
|
|
607
|
+
const id = command.input.ClientId ?? '';
|
|
608
|
+
const client = this.clients.get(id);
|
|
609
|
+
return {
|
|
610
|
+
UserPoolClient: { ClientId: id, ClientSecret: client?.secret },
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
if (command instanceof CreateUserPoolClientCommand) {
|
|
614
|
+
this.calls.push('CreateUserPoolClient');
|
|
615
|
+
this.created += 1;
|
|
616
|
+
const id = 'client-' + this.created;
|
|
617
|
+
const secret = 'never-log-secret-' + this.created;
|
|
618
|
+
this.clients.set(id, { name: command.input.ClientName ?? '', secret });
|
|
619
|
+
return { UserPoolClient: { ClientId: id, ClientSecret: secret } };
|
|
620
|
+
}
|
|
621
|
+
if (command instanceof DeleteUserPoolClientCommand) {
|
|
622
|
+
this.calls.push('DeleteUserPoolClient:' + command.input.ClientId);
|
|
623
|
+
this.clients.delete(command.input.ClientId ?? '');
|
|
624
|
+
return {};
|
|
625
|
+
}
|
|
626
|
+
throw new Error('Unexpected Cognito command.');
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
readonly secrets = {
|
|
631
|
+
send: async (command: unknown) => {
|
|
632
|
+
if (command instanceof GetSecretValueCommand) {
|
|
633
|
+
const stage = command.input.VersionStage ?? '';
|
|
634
|
+
this.calls.push('GetSecretValue:' + stage);
|
|
635
|
+
const version = this.versions.find(({ stages }) =>
|
|
636
|
+
stages.includes(stage),
|
|
637
|
+
);
|
|
638
|
+
if (!version) {
|
|
639
|
+
throw Object.assign(new Error('absent'), {
|
|
640
|
+
$metadata: {},
|
|
641
|
+
name: 'ResourceNotFoundException',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return { SecretString: version.value, VersionId: version.id };
|
|
645
|
+
}
|
|
646
|
+
if (command instanceof PutSecretValueCommand) {
|
|
647
|
+
this.calls.push('PutSecretValue');
|
|
648
|
+
this.label('AWSPREVIOUS');
|
|
649
|
+
for (const version of this.versions) {
|
|
650
|
+
if (version.stages.includes('AWSCURRENT')) {
|
|
651
|
+
version.stages = ['AWSPREVIOUS'];
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
this.versions.push({
|
|
655
|
+
id: 'v' + (this.versions.length + 1),
|
|
656
|
+
stages: ['AWSCURRENT'],
|
|
657
|
+
value: command.input.SecretString ?? '',
|
|
658
|
+
});
|
|
659
|
+
return {};
|
|
660
|
+
}
|
|
661
|
+
if (command instanceof UpdateSecretVersionStageCommand) {
|
|
662
|
+
const { MoveToVersionId, RemoveFromVersionId, VersionStage } =
|
|
663
|
+
command.input;
|
|
664
|
+
this.calls.push(
|
|
665
|
+
'UpdateSecretVersionStage:' +
|
|
666
|
+
VersionStage +
|
|
667
|
+
':' +
|
|
668
|
+
(MoveToVersionId ?? '-') +
|
|
669
|
+
':' +
|
|
670
|
+
RemoveFromVersionId,
|
|
671
|
+
);
|
|
672
|
+
if (VersionStage === 'AWSCURRENT') {
|
|
673
|
+
this.label('AWSPREVIOUS');
|
|
674
|
+
this.version(RemoveFromVersionId).stages = ['AWSPREVIOUS'];
|
|
675
|
+
this.version(MoveToVersionId).stages = ['AWSCURRENT'];
|
|
676
|
+
} else {
|
|
677
|
+
this.label(VersionStage ?? '');
|
|
678
|
+
}
|
|
679
|
+
return {};
|
|
680
|
+
}
|
|
681
|
+
throw new Error('Unexpected Secrets Manager command.');
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
readonly logger = createLogger({
|
|
686
|
+
component: 'machine-client-provisioner',
|
|
687
|
+
feature: 'authorization',
|
|
688
|
+
operation: 'machine-client.provision',
|
|
689
|
+
sink: (entry) => this.logs.push(entry as LogEntry),
|
|
690
|
+
slice: SLICE,
|
|
691
|
+
stage: 'development',
|
|
692
|
+
workspace: 'fixture',
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
private label(stage: string) {
|
|
696
|
+
for (const version of this.versions) {
|
|
697
|
+
version.stages = version.stages.filter((label) => label !== stage);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private version(id: string | undefined) {
|
|
702
|
+
const version = this.versions.find((candidate) => candidate.id === id);
|
|
703
|
+
if (!version) throw new Error('Unknown secret version.');
|
|
704
|
+
return version;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async invoke(
|
|
708
|
+
requestType: 'Create' | 'Delete' | 'Update',
|
|
709
|
+
generation: string,
|
|
710
|
+
options: { physicalResourceId?: string; retire?: boolean } = {},
|
|
711
|
+
) {
|
|
712
|
+
this.calls.length = 0;
|
|
713
|
+
let release!: () => void;
|
|
714
|
+
let settled = false;
|
|
715
|
+
const provision = createMachineClientProvisioner({
|
|
716
|
+
cognito: this.cognito,
|
|
717
|
+
logger: this.logger,
|
|
718
|
+
respond: (_url, body) => {
|
|
719
|
+
this.responses.push(JSON.parse(body));
|
|
720
|
+
return new Promise<void>((resolve) => {
|
|
721
|
+
release = resolve;
|
|
722
|
+
});
|
|
723
|
+
},
|
|
724
|
+
secrets: this.secrets,
|
|
725
|
+
});
|
|
726
|
+
const event: MachineClientProvisionerEvent = {
|
|
727
|
+
LogicalResourceId: 'MachineClient',
|
|
728
|
+
PhysicalResourceId:
|
|
729
|
+
requestType === 'Create'
|
|
730
|
+
? undefined
|
|
731
|
+
: (options.physicalResourceId ?? PHYSICAL),
|
|
732
|
+
RequestId: 'request-' + (this.responses.length + 1),
|
|
733
|
+
RequestType: requestType,
|
|
734
|
+
ResourceProperties: {
|
|
735
|
+
ClientName: SLICE + '-machine-development-' + generation,
|
|
736
|
+
CredentialGeneration: generation,
|
|
737
|
+
ResourceServerIdentifier: SLICE + '-development',
|
|
738
|
+
RetirePreviousGeneration: options.retire ? 'true' : 'false',
|
|
739
|
+
Scopes: [SLICE + '-development/proof-read'],
|
|
740
|
+
SecretArn: SECRET,
|
|
741
|
+
UserPoolId: POOL,
|
|
742
|
+
},
|
|
743
|
+
ResponseURL: 'https://cloudformation-response.invalid/response',
|
|
744
|
+
StackId: 'stack-1',
|
|
745
|
+
};
|
|
746
|
+
const expected = this.responses.length + 1;
|
|
747
|
+
const running = provision(event).then(() => {
|
|
748
|
+
settled = true;
|
|
749
|
+
});
|
|
750
|
+
for (let turn = 0; this.responses.length < expected && turn < 100; turn++) {
|
|
751
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
752
|
+
}
|
|
753
|
+
expect(this.responses).toHaveLength(expected);
|
|
754
|
+
// CloudFormation must have the response before the handler may finish.
|
|
755
|
+
expect(settled).toBe(false);
|
|
756
|
+
release();
|
|
757
|
+
await running;
|
|
758
|
+
return this.responses[expected - 1];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
current() {
|
|
762
|
+
return JSON.parse(
|
|
763
|
+
this.versions.find(({ stages }) => stages.includes('AWSCURRENT'))
|
|
764
|
+
?.value ?? 'null',
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
describe('generated machine-client provisioner', () => {
|
|
770
|
+
it('creates, replays, rotates, retires, and deletes with exact outputs', async () => {
|
|
771
|
+
const aws = new MemoryAws();
|
|
772
|
+
|
|
773
|
+
const created = await aws.invoke('Create', 'blue-1');
|
|
774
|
+
expect(created).toMatchObject({
|
|
775
|
+
Data: { AcceptedClientIdsJson: '["client-1"]', ClientId: 'client-1' },
|
|
776
|
+
PhysicalResourceId: PHYSICAL,
|
|
777
|
+
Status: 'SUCCESS',
|
|
778
|
+
});
|
|
779
|
+
expect(aws.versions[0].value).toBe(
|
|
780
|
+
'{"clientId":"client-1","clientSecret":"never-log-secret-1","generation":"blue-1"}',
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
const replay = await aws.invoke('Update', 'blue-1');
|
|
784
|
+
expect(aws.calls).toEqual([
|
|
785
|
+
'GetSecretValue:AWSCURRENT',
|
|
786
|
+
'GetSecretValue:AWSPREVIOUS',
|
|
787
|
+
'ListUserPoolClients',
|
|
788
|
+
'DescribeUserPoolClient',
|
|
789
|
+
]);
|
|
790
|
+
expect(replay.Data).toEqual(created.Data);
|
|
791
|
+
|
|
792
|
+
const rotated = await aws.invoke('Update', 'green-2');
|
|
793
|
+
expect(rotated.Data).toEqual({
|
|
794
|
+
AcceptedClientIdsJson: '["client-2","client-1"]',
|
|
795
|
+
ClientId: 'client-2',
|
|
796
|
+
});
|
|
797
|
+
expect((await aws.invoke('Update', 'green-2')).Data).toEqual(rotated.Data);
|
|
798
|
+
|
|
799
|
+
const retired = await aws.invoke('Update', 'green-2', { retire: true });
|
|
800
|
+
expect(aws.calls).toContain('DeleteUserPoolClient:client-1');
|
|
801
|
+
expect(aws.calls.at(-1)).toBe('UpdateSecretVersionStage:AWSPREVIOUS:-:v1');
|
|
802
|
+
expect(retired.Data).toEqual({
|
|
803
|
+
AcceptedClientIdsJson: '["client-2"]',
|
|
804
|
+
ClientId: 'client-2',
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(await aws.invoke('Delete', 'green-2')).toMatchObject({
|
|
808
|
+
Data: {},
|
|
809
|
+
Status: 'SUCCESS',
|
|
810
|
+
});
|
|
811
|
+
expect(aws.calls).toContain('DeleteUserPoolClient:client-2');
|
|
812
|
+
expect(aws.clients.size).toBe(0);
|
|
813
|
+
|
|
814
|
+
const serialized = JSON.stringify([aws.logs, aws.responses]);
|
|
815
|
+
expect(serialized).not.toContain('never-log-secret');
|
|
816
|
+
expect(
|
|
817
|
+
aws.logs.every(
|
|
818
|
+
({ component }) => component === 'machine-client-provisioner',
|
|
819
|
+
),
|
|
820
|
+
).toBe(true);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('rolls back to the displaced generation without writing a secret', async () => {
|
|
824
|
+
const aws = new MemoryAws();
|
|
825
|
+
await aws.invoke('Create', 'blue-1');
|
|
826
|
+
await aws.invoke('Update', 'green-2');
|
|
827
|
+
|
|
828
|
+
const restored = await aws.invoke('Update', 'blue-1');
|
|
829
|
+
expect(aws.calls).toEqual([
|
|
830
|
+
'GetSecretValue:AWSCURRENT',
|
|
831
|
+
'GetSecretValue:AWSPREVIOUS',
|
|
832
|
+
'ListUserPoolClients',
|
|
833
|
+
'DescribeUserPoolClient',
|
|
834
|
+
'UpdateSecretVersionStage:AWSCURRENT:v1:v2',
|
|
835
|
+
]);
|
|
836
|
+
expect(restored.Data).toEqual({
|
|
837
|
+
AcceptedClientIdsJson: '["client-1","client-2"]',
|
|
838
|
+
ClientId: 'client-1',
|
|
839
|
+
});
|
|
840
|
+
expect(aws.current().generation).toBe('blue-1');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('refuses unsafe rotations before creating any Cognito client', async () => {
|
|
844
|
+
const aws = new MemoryAws();
|
|
845
|
+
await aws.invoke('Create', 'blue-1');
|
|
846
|
+
|
|
847
|
+
for (const [generation, retire] of [
|
|
848
|
+
['blue-3', false],
|
|
849
|
+
['green-2', true],
|
|
850
|
+
] as const) {
|
|
851
|
+
const refused = await aws.invoke('Update', generation, { retire });
|
|
852
|
+
expect(refused).toMatchObject({ Reason: 'ValueError', Status: 'FAILED' });
|
|
853
|
+
expect(aws.calls).toEqual([
|
|
854
|
+
'GetSecretValue:AWSCURRENT',
|
|
855
|
+
'GetSecretValue:AWSPREVIOUS',
|
|
856
|
+
]);
|
|
857
|
+
}
|
|
858
|
+
await aws.invoke('Update', 'green-2');
|
|
859
|
+
expect(await aws.invoke('Update', 'blue-3')).toMatchObject({
|
|
860
|
+
Reason: 'ValueError',
|
|
861
|
+
Status: 'FAILED',
|
|
862
|
+
});
|
|
863
|
+
expect(aws.calls).not.toContain('CreateUserPoolClient');
|
|
864
|
+
expect(aws.clients.size).toBe(2);
|
|
865
|
+
expect(aws.logs.at(-1)).toMatchObject({
|
|
866
|
+
attributes: { errorCode: 'ValueError', requestType: 'Update' },
|
|
867
|
+
level: 'ERROR',
|
|
868
|
+
outcome: 'failure',
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('acknowledges a Delete for another physical id without touching a client', async () => {
|
|
873
|
+
const aws = new MemoryAws();
|
|
874
|
+
await aws.invoke('Create', 'blue-1');
|
|
875
|
+
|
|
876
|
+
const foreign = await aws.invoke('Delete', 'blue-1', {
|
|
877
|
+
physicalResourceId: 'replaced-resource',
|
|
878
|
+
});
|
|
879
|
+
expect(foreign).toMatchObject({
|
|
880
|
+
PhysicalResourceId: 'replaced-resource',
|
|
881
|
+
Status: 'SUCCESS',
|
|
882
|
+
});
|
|
883
|
+
expect(aws.calls).toEqual([]);
|
|
884
|
+
expect(aws.clients.size).toBe(1);
|
|
885
|
+
});
|
|
886
|
+
});
|
|
227
887
|
`;
|
|
888
|
+
}
|
|
228
889
|
/** @internal */
|
|
229
890
|
function renderStandardSliceAuthorizationInfrastructureFiles(name, pascalName) {
|
|
230
891
|
return {
|
|
231
|
-
'authorization/machine-client-provisioner.
|
|
232
|
-
'
|
|
233
|
-
name = "ebk-${name}-python-runtime"
|
|
234
|
-
version = "0.0.0"
|
|
235
|
-
requires-python = ">=3.12"
|
|
236
|
-
dependencies = []
|
|
237
|
-
|
|
238
|
-
[tool.uv]
|
|
239
|
-
package = false
|
|
240
|
-
`,
|
|
241
|
-
'uv.lock': `version = 1
|
|
242
|
-
revision = 3
|
|
243
|
-
requires-python = ">=3.12"
|
|
244
|
-
|
|
245
|
-
[[package]]
|
|
246
|
-
name = "ebk-${name}-python-runtime"
|
|
247
|
-
version = "0.0.0"
|
|
248
|
-
source = { virtual = "." }
|
|
249
|
-
`,
|
|
892
|
+
'authorization/machine-client-provisioner.ts': machineClientProvisionerSource(name),
|
|
893
|
+
'authorization/machine-client-provisioner.spec.ts': machineClientProvisionerSpec(name),
|
|
250
894
|
'authorization/groups.mjs': `/** Exact physical Cognito identities; product policy keeps its logical roles. */
|
|
251
895
|
export function sliceAuthorizationGroupNames(stage) {
|
|
252
896
|
if (['development', 'staging', 'production'].includes(stage)) {
|
|
@@ -580,37 +1224,77 @@ line.
|
|
|
580
1224
|
|
|
581
1225
|
## Begin an overlap
|
|
582
1226
|
|
|
583
|
-
1. Read the current non-secret generation
|
|
584
|
-
|
|
1227
|
+
1. Read the current non-secret generation: the value last deployed with
|
|
1228
|
+
\`EBK_MACHINE_CREDENTIAL_GENERATION\`, or \`blue-1\` if the stage was never
|
|
1229
|
+
rotated. The proof result in step 5 reports it as \`credentialGeneration\`.
|
|
585
1230
|
2. Select the other slot and a new positive generation number, for example
|
|
586
1231
|
\`blue-1\` to \`green-2\`.
|
|
587
1232
|
3. Set \`EBK_MACHINE_CREDENTIAL_GENERATION\` to that generation and leave
|
|
588
1233
|
\`EBK_MACHINE_RETIRE_PREVIOUS_GENERATION\` unset.
|
|
589
|
-
4. Deploy through the
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1234
|
+
4. Deploy the Slice through the governed root lifecycle with that variable
|
|
1235
|
+
in the same shell, for example:
|
|
1236
|
+
|
|
1237
|
+
\`\`\`bash
|
|
1238
|
+
EBK_MACHINE_CREDENTIAL_GENERATION=green-2 ebk deploy development <slice> \\
|
|
1239
|
+
--profile=<profile> --expectedRoleArn=<role-arn>
|
|
1240
|
+
\`\`\`
|
|
1241
|
+
|
|
1242
|
+
A local \`nx run <slice>:deploy\` is refused outside that lifecycle. The
|
|
1243
|
+
AWS-internal provisioner creates the replacement client and moves its
|
|
1244
|
+
credential to \`AWSCURRENT\`; the former credential remains \`AWSPREVIOUS\`
|
|
1245
|
+
and its Cognito client remains valid during the overlap.
|
|
593
1246
|
5. Prove the server-side token-minting journey reads \`AWSCURRENT\`, obtains a
|
|
594
1247
|
bounded access token, and completes its narrow capability smoke. Evidence
|
|
595
1248
|
may contain the client ID, generation, scope names, and outcome, but never a
|
|
596
|
-
secret or access token.
|
|
1249
|
+
secret or access token. Read that result from the Slice's deployed
|
|
1250
|
+
machine-authorization proof function, which no EBK command invokes yet: it
|
|
1251
|
+
must return \`outcome\` \`success\`, \`probeStatus\` 200, and the new
|
|
1252
|
+
\`credentialGeneration\`.
|
|
1253
|
+
|
|
1254
|
+
Every deploy of the Slice reads these variables, and an omitted
|
|
1255
|
+
\`EBK_MACHINE_CREDENTIAL_GENERATION\` requests \`blue-1\`. Keep it set to the
|
|
1256
|
+
current generation for every later deploy of that stage; otherwise the
|
|
1257
|
+
provisioner rolls back to \`blue-1\`, refuses the deploy, or begins an
|
|
1258
|
+
unplanned rotation. Local and workflow deploys of one stage must request the
|
|
1259
|
+
same generation.
|
|
597
1260
|
|
|
598
1261
|
## Retire the previous generation
|
|
599
1262
|
|
|
600
1263
|
Only after the new generation is proved, set
|
|
601
1264
|
\`EBK_MACHINE_RETIRE_PREVIOUS_GENERATION=true\` and deploy the same generation
|
|
602
|
-
again
|
|
1265
|
+
again the same way. The AWS-internal provisioner deletes the previous Cognito
|
|
603
1266
|
client and removes its \`AWSPREVIOUS\` label without returning either
|
|
604
1267
|
credential. Clear the retirement flag after the successful deploy.
|
|
605
1268
|
|
|
606
|
-
If the replacement cannot mint and use a token,
|
|
607
|
-
|
|
1269
|
+
If the replacement cannot mint and use a token, deploy the prior generation
|
|
1270
|
+
again before retirement. The provisioner recognizes that exact
|
|
608
1271
|
\`AWSPREVIOUS\` generation, atomically promotes it back to \`AWSCURRENT\`, and
|
|
609
1272
|
keeps the failed replacement as the new rollback generation. Prove the restored
|
|
610
1273
|
generation before retiring anything. Never retire in the same deployment that
|
|
611
1274
|
creates a generation, reuse the current color for a new generation, invoke SST
|
|
612
1275
|
directly, or use the interactive operator-secret commands for this
|
|
613
1276
|
provider-generated credential.
|
|
1277
|
+
|
|
1278
|
+
## Rotate through the protected Deploy workflow
|
|
1279
|
+
|
|
1280
|
+
The generated protected Deploy workflow reads both variables from the
|
|
1281
|
+
deploying stage's GitHub environment, such as \`staging\` or \`production\`,
|
|
1282
|
+
and passes each to its deployment only when it is set. Set them as variables
|
|
1283
|
+
of that environment, never at repository or organization scope; the GitHub
|
|
1284
|
+
bootstrap report refuses those scopes. Before it assumes any AWS role, the
|
|
1285
|
+
workflow refuses a generation other than \`blue-N\` or \`green-N\`, a
|
|
1286
|
+
retirement flag other than \`true\` or \`false\`, a retirement without an
|
|
1287
|
+
explicit generation, and a retirement deploy in \`affected\` scope.
|
|
1288
|
+
|
|
1289
|
+
1. Set \`EBK_MACHINE_CREDENTIAL_GENERATION\` in the stage's environment to the
|
|
1290
|
+
new generation and run the protected Deploy for that stage with
|
|
1291
|
+
\`lifecycle\` \`full\`. The default \`affected\` scope skips a Slice whose
|
|
1292
|
+
source is unchanged, so it would not rotate.
|
|
1293
|
+
2. Prove the new generation as in step 5 of "Begin an overlap".
|
|
1294
|
+
3. Set \`EBK_MACHINE_RETIRE_PREVIOUS_GENERATION\` to \`true\` in the same
|
|
1295
|
+
environment and run the protected Deploy again with \`lifecycle\` \`full\`.
|
|
1296
|
+
4. After that deploy succeeds, delete the retirement variable or set it to
|
|
1297
|
+
\`false\`. Keep the generation variable at the current generation.
|
|
614
1298
|
`,
|
|
615
1299
|
};
|
|
616
1300
|
}
|