cloudmason2 1.7.999 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/new_app.js +3 -196
- package/commands/set_repo.js +271 -0
- package/commands/update_listing.js +13 -45
- package/main.js +10 -3
- package/package.json +1 -1
package/commands/new_app.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { S3Client, PutObjectCommand,
|
|
4
|
-
const { IAMClient, GetRoleCommand, CreateRoleCommand, PutRolePolicyCommand, GetOpenIDConnectProviderCommand, CreateOpenIDConnectProviderCommand } = require("@aws-sdk/client-iam");
|
|
5
|
-
const { STSClient, GetCallerIdentityCommand } = require("@aws-sdk/client-sts");
|
|
3
|
+
const { S3Client, PutObjectCommand, ListObjectsV2Command } = require("@aws-sdk/client-s3");
|
|
6
4
|
const OrgConfig = require('./helpers/org_config');
|
|
7
5
|
const Apps = require('./helpers/apps');
|
|
8
6
|
|
|
@@ -16,8 +14,8 @@ const MANIFEST_TEMPLATE = {
|
|
|
16
14
|
versions: []
|
|
17
15
|
};
|
|
18
16
|
|
|
19
|
-
// new-app: create the app's core assets in s3
|
|
20
|
-
//
|
|
17
|
+
// new-app: create the app's core assets in s3 and the local template folder.
|
|
18
|
+
// Listing content is set by update-listing; the CI role is managed by set-repo
|
|
21
19
|
exports.main = async function(args){
|
|
22
20
|
const cfg = OrgConfig.read();
|
|
23
21
|
if (!cfg){ throw new Error('No org config found. Run set-org') }
|
|
@@ -45,13 +43,6 @@ exports.main = async function(args){
|
|
|
45
43
|
await Apps.addToIndex(s3, bucket, prefix, appPrefix);
|
|
46
44
|
}
|
|
47
45
|
createLocalMarketplace();
|
|
48
|
-
|
|
49
|
-
// --- III REPO ROLE ---
|
|
50
|
-
if (args.repo){
|
|
51
|
-
if (!/^[\w.-]+\/[\w.-]+$/.test(args.repo)){ throw new Error('Invalid repo. Use org/repo format') }
|
|
52
|
-
const manifest = await getManifest(s3, bucket, appPrefix);
|
|
53
|
-
await ensureGithubRole(args.repo, bucket, appPrefix, appName, manifest.productId);
|
|
54
|
-
}
|
|
55
46
|
return true;
|
|
56
47
|
}
|
|
57
48
|
|
|
@@ -104,187 +95,3 @@ function createLocalMarketplace(){
|
|
|
104
95
|
});
|
|
105
96
|
console.log(`Created ${destDir}`);
|
|
106
97
|
}
|
|
107
|
-
|
|
108
|
-
// Trust policy allowing GitHub Actions OIDC assumption from one repo.
|
|
109
|
-
// Shared with update-listing's -repo trust update
|
|
110
|
-
exports.githubTrustPolicy = function(oidcArn, repo){
|
|
111
|
-
return {
|
|
112
|
-
Version: "2012-10-17",
|
|
113
|
-
Statement: [
|
|
114
|
-
{
|
|
115
|
-
Effect: "Allow",
|
|
116
|
-
Principal: { Federated: oidcArn },
|
|
117
|
-
Action: "sts:AssumeRoleWithWebIdentity",
|
|
118
|
-
Condition: {
|
|
119
|
-
StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
|
|
120
|
-
StringLike: { "token.actions.githubusercontent.com:sub": `repo:${repo}:*` }
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
]
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Create a role GitHub Actions can assume via OIDC from the given repo.
|
|
128
|
-
// Permissions cover update-listing, update-app, launch, and publish - not new-app
|
|
129
|
-
async function ensureGithubRole(repo, bucket, appPrefix, appName, productId){
|
|
130
|
-
const roleName = `mason-gha-${appName}`;
|
|
131
|
-
const iam = new IAMClient({ region: 'us-east-1' });
|
|
132
|
-
const sts = new STSClient({ region: 'us-east-1' });
|
|
133
|
-
const accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
134
|
-
|
|
135
|
-
const oidcArn = await ensureOidcProvider(iam, accountId);
|
|
136
|
-
|
|
137
|
-
// Reuse the role if it already exists - the permissions policy below is
|
|
138
|
-
// still re-applied, so re-running new-app refreshes an existing role
|
|
139
|
-
let roleArn = null;
|
|
140
|
-
try {
|
|
141
|
-
const existing = await iam.send(new GetRoleCommand({ RoleName: roleName }));
|
|
142
|
-
console.log(`Role ${roleName} already exists (${existing.Role.Arn})`);
|
|
143
|
-
roleArn = existing.Role.Arn;
|
|
144
|
-
} catch (e){
|
|
145
|
-
if (!/NoSuchEntity/.test(e)){ throw e }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (!roleArn){
|
|
149
|
-
// Trust: only GitHub Actions workflows from this repo
|
|
150
|
-
const trustPolicy = exports.githubTrustPolicy(oidcArn, repo);
|
|
151
|
-
const created = await iam.send(new CreateRoleCommand({
|
|
152
|
-
RoleName: roleName,
|
|
153
|
-
AssumeRolePolicyDocument: JSON.stringify(trustPolicy),
|
|
154
|
-
Description: `Mason CI role for ${appName} (github.com/${repo})`
|
|
155
|
-
}));
|
|
156
|
-
roleArn = created.Role.Arn;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Scoped to the app prefix, product id, and app stack names where the
|
|
160
|
-
// services support it. EC2 build permissions cannot be scoped
|
|
161
|
-
if (!productId){ console.log('WARNING: No productId in manifest. Marketplace access is scoped to all products. Re-create the role after update-listing -pid to scope it') }
|
|
162
|
-
const marketplaceEntity = productId
|
|
163
|
-
? `arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/AmiProduct/${productId}`
|
|
164
|
-
: `arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/AmiProduct/*`;
|
|
165
|
-
const policy = {
|
|
166
|
-
Version: "2012-10-17",
|
|
167
|
-
Statement: [
|
|
168
|
-
{
|
|
169
|
-
Sid: "AppPrefixWrite",
|
|
170
|
-
Effect: "Allow",
|
|
171
|
-
Action: ["s3:GetObject", "s3:PutObject"],
|
|
172
|
-
Resource: `arn:aws:s3:::${bucket}/${appPrefix}/*`
|
|
173
|
-
},
|
|
174
|
-
{
|
|
175
|
-
Sid: "AppPrefixList",
|
|
176
|
-
Effect: "Allow",
|
|
177
|
-
Action: "s3:ListBucket",
|
|
178
|
-
Resource: `arn:aws:s3:::${bucket}`,
|
|
179
|
-
Condition: { StringLike: { "s3:prefix": `${appPrefix}/*` } }
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
Sid: "MarketplacePublish",
|
|
183
|
-
Effect: "Allow",
|
|
184
|
-
Action: ["aws-marketplace:StartChangeSet", "aws-marketplace:DescribeChangeSet", "aws-marketplace:DescribeEntity"],
|
|
185
|
-
Resource: [
|
|
186
|
-
marketplaceEntity,
|
|
187
|
-
`arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/ChangeSet/*`
|
|
188
|
-
]
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
Sid: "AppStacks",
|
|
192
|
-
Effect: "Allow",
|
|
193
|
-
Action: ["cloudformation:CreateStack", "cloudformation:UpdateStack", "cloudformation:DescribeStacks"],
|
|
194
|
-
Resource: `arn:aws:cloudformation:*:${accountId}:stack/${appName}-*/*`
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
Sid: "ReadTemplateSummary",
|
|
198
|
-
Effect: "Allow",
|
|
199
|
-
// launch calls GetTemplateSummary with a TemplateURL, which IAM
|
|
200
|
-
// evaluates against * rather than a stack ARN
|
|
201
|
-
Action: "cloudformation:GetTemplateSummary",
|
|
202
|
-
Resource: "*"
|
|
203
|
-
},
|
|
204
|
-
{
|
|
205
|
-
Sid: "LaunchStackServices",
|
|
206
|
-
Effect: "Allow",
|
|
207
|
-
// CloudFormation provisions stack resources with the caller's
|
|
208
|
-
// credentials. Certs, DNS records, and user pools get
|
|
209
|
-
// unpredictable ARNs, so these cannot be resource-scoped
|
|
210
|
-
Action: ["acm:*", "route53:*", "cognito-idp:*"],
|
|
211
|
-
Resource: "*"
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
Sid: "AppStackIam",
|
|
215
|
-
Effect: "Allow",
|
|
216
|
-
Action: [
|
|
217
|
-
"iam:CreateRole", "iam:DeleteRole", "iam:GetRole", "iam:UpdateRole", "iam:TagRole", "iam:UntagRole",
|
|
218
|
-
"iam:PutRolePolicy", "iam:DeleteRolePolicy", "iam:GetRolePolicy", "iam:ListRolePolicies",
|
|
219
|
-
"iam:AttachRolePolicy", "iam:DetachRolePolicy", "iam:ListAttachedRolePolicies", "iam:PassRole",
|
|
220
|
-
"iam:CreateInstanceProfile", "iam:DeleteInstanceProfile", "iam:GetInstanceProfile",
|
|
221
|
-
"iam:AddRoleToInstanceProfile", "iam:RemoveRoleFromInstanceProfile", "iam:TagInstanceProfile"
|
|
222
|
-
],
|
|
223
|
-
// CFN-generated role/profile names start with the stack name,
|
|
224
|
-
// and launch names every stack <app>-<title>
|
|
225
|
-
Resource: [
|
|
226
|
-
`arn:aws:iam::${accountId}:role/${appName}-*`,
|
|
227
|
-
`arn:aws:iam::${accountId}:instance-profile/${appName}-*`
|
|
228
|
-
]
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
Sid: "AppStackBuckets",
|
|
232
|
-
Effect: "Allow",
|
|
233
|
-
Action: "s3:*",
|
|
234
|
-
Resource: [`arn:aws:s3:::${appName}-*`, `arn:aws:s3:::${appName}-*/*`]
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
Sid: "AppStackTables",
|
|
238
|
-
Effect: "Allow",
|
|
239
|
-
Action: "dynamodb:*",
|
|
240
|
-
Resource: `arn:aws:dynamodb:*:${accountId}:table/${appName}-*`
|
|
241
|
-
},
|
|
242
|
-
{
|
|
243
|
-
Sid: "ReadRoleParam",
|
|
244
|
-
Effect: "Allow",
|
|
245
|
-
Action: "ssm:GetParameter",
|
|
246
|
-
Resource: `arn:aws:ssm:us-east-1:${accountId}:parameter/mason-cdn-${bucket}`
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
Sid: "AmiBuildAndLaunch",
|
|
250
|
-
Effect: "Allow",
|
|
251
|
-
Action: ["ec2:*", "autoscaling:*", "elasticloadbalancing:*"],
|
|
252
|
-
Resource: "*"
|
|
253
|
-
}
|
|
254
|
-
]
|
|
255
|
-
};
|
|
256
|
-
await iam.send(new PutRolePolicyCommand({
|
|
257
|
-
RoleName: roleName,
|
|
258
|
-
PolicyName: 'mason-app-access',
|
|
259
|
-
PolicyDocument: JSON.stringify(policy)
|
|
260
|
-
}));
|
|
261
|
-
|
|
262
|
-
console.log(`Configured role ${roleName} (${roleArn})`);
|
|
263
|
-
console.log(`Use in GitHub Actions with: role-to-assume: ${roleArn}`);
|
|
264
|
-
return roleArn;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function ensureOidcProvider(iam, accountId){
|
|
268
|
-
const arn = `arn:aws:iam::${accountId}:oidc-provider/token.actions.githubusercontent.com`;
|
|
269
|
-
try {
|
|
270
|
-
await iam.send(new GetOpenIDConnectProviderCommand({ OpenIDConnectProviderArn: arn }));
|
|
271
|
-
return arn;
|
|
272
|
-
} catch (e){
|
|
273
|
-
if (!/NoSuchEntity/.test(e)){ throw e }
|
|
274
|
-
}
|
|
275
|
-
await iam.send(new CreateOpenIDConnectProviderCommand({
|
|
276
|
-
Url: 'https://token.actions.githubusercontent.com',
|
|
277
|
-
ClientIDList: ['sts.amazonaws.com'],
|
|
278
|
-
ThumbprintList: ['6938fd4d98bab03faadb97b34396831e3780aea1']
|
|
279
|
-
}));
|
|
280
|
-
console.log('Created GitHub OIDC identity provider');
|
|
281
|
-
return arn;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
async function getManifest(s3, bucket, appPrefix){
|
|
285
|
-
const r = await s3.send(new GetObjectCommand({
|
|
286
|
-
Bucket: bucket,
|
|
287
|
-
Key: `${appPrefix}/manifest.json`
|
|
288
|
-
}));
|
|
289
|
-
return JSON.parse(await r.Body.transformToString());
|
|
290
|
-
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
|
|
4
|
+
const { CloudFormationClient, GetTemplateSummaryCommand, DescribeTypeCommand } = require("@aws-sdk/client-cloudformation");
|
|
5
|
+
const { IAMClient, GetRoleCommand, CreateRoleCommand, PutRolePolicyCommand, UpdateAssumeRolePolicyCommand, GetOpenIDConnectProviderCommand, CreateOpenIDConnectProviderCommand } = require("@aws-sdk/client-iam");
|
|
6
|
+
const { STSClient, GetCallerIdentityCommand } = require("@aws-sdk/client-sts");
|
|
7
|
+
const OrgConfig = require('./helpers/org_config');
|
|
8
|
+
const Apps = require('./helpers/apps');
|
|
9
|
+
|
|
10
|
+
// Inline role policies cap at 10240 chars; collapse derived actions if we get close
|
|
11
|
+
const POLICY_CHAR_LIMIT = 10000;
|
|
12
|
+
|
|
13
|
+
// set-repo: create or update the app's GitHub Actions CI role. Sets the trust
|
|
14
|
+
// policy to the given repo and derives the stack template's required permissions
|
|
15
|
+
// from the CloudFormation registry. Run locally under elevated permissions -
|
|
16
|
+
// the CI role deliberately cannot modify itself
|
|
17
|
+
exports.main = async function(args){
|
|
18
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(args.repo)){ throw new Error('Invalid repo. Use org/repo format') }
|
|
19
|
+
|
|
20
|
+
const cfg = OrgConfig.read();
|
|
21
|
+
if (!cfg){ throw new Error('No org config found. Run set-org') }
|
|
22
|
+
const { bucket, prefix } = parseCdn(cfg.cdn);
|
|
23
|
+
const s3 = new S3Client({ region: cfg.region });
|
|
24
|
+
|
|
25
|
+
const appName = args.app.toLowerCase();
|
|
26
|
+
const app = await Apps.resolveApp(s3, bucket, prefix, appName);
|
|
27
|
+
if (!app){ throw new Error('No app named ' + args.app + '. Run new-app') }
|
|
28
|
+
const { appPrefix, manifest } = app;
|
|
29
|
+
|
|
30
|
+
// --- I GET TEMPLATE ---
|
|
31
|
+
let stackText = null;
|
|
32
|
+
if (args.stack){
|
|
33
|
+
const stackPath = path.resolve(args.stack);
|
|
34
|
+
if (!fs.existsSync(stackPath)){ throw new Error('Stack not found: ' + stackPath) }
|
|
35
|
+
stackText = fs.readFileSync(stackPath, 'utf-8');
|
|
36
|
+
} else {
|
|
37
|
+
const latest = [...(manifest.versions || [])]
|
|
38
|
+
.sort((a, b)=>{ return String(b.updated_on || '').localeCompare(String(a.updated_on || '')) })[0];
|
|
39
|
+
if (latest){
|
|
40
|
+
const vFolder = latest.version.split('.').slice(0, 2).join('.');
|
|
41
|
+
stackText = await getFileText(s3, bucket, `${appPrefix}/versions/${vFolder}/stack.yaml`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!stackText){
|
|
45
|
+
console.log('No stack template found. Applying baseline permissions only. Pass -stack to derive template permissions');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- II DERIVE PERMISSIONS FROM THE CF REGISTRY ---
|
|
49
|
+
let derived = [];
|
|
50
|
+
if (stackText){
|
|
51
|
+
derived = await derivePermissions(cfg.region, stackText);
|
|
52
|
+
console.log(`Derived ${derived.length} actions from the template`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- III ENSURE ROLE + TRUST ---
|
|
56
|
+
const iam = new IAMClient({ region: 'us-east-1' });
|
|
57
|
+
const sts = new STSClient({ region: 'us-east-1' });
|
|
58
|
+
const accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
59
|
+
const oidcArn = await ensureOidcProvider(iam, accountId);
|
|
60
|
+
|
|
61
|
+
const roleName = `mason-gha-${appName}`;
|
|
62
|
+
const trustPolicy = githubTrustPolicy(oidcArn, args.repo);
|
|
63
|
+
let roleArn;
|
|
64
|
+
try {
|
|
65
|
+
const existing = await iam.send(new GetRoleCommand({ RoleName: roleName }));
|
|
66
|
+
roleArn = existing.Role.Arn;
|
|
67
|
+
await iam.send(new UpdateAssumeRolePolicyCommand({
|
|
68
|
+
RoleName: roleName,
|
|
69
|
+
PolicyDocument: JSON.stringify(trustPolicy)
|
|
70
|
+
}));
|
|
71
|
+
console.log(`Updated ${roleName} trust to repo ${args.repo}`);
|
|
72
|
+
} catch (e){
|
|
73
|
+
if (!/NoSuchEntity/.test(e)){ throw e }
|
|
74
|
+
const created = await iam.send(new CreateRoleCommand({
|
|
75
|
+
RoleName: roleName,
|
|
76
|
+
AssumeRolePolicyDocument: JSON.stringify(trustPolicy),
|
|
77
|
+
Description: `Mason CI role for ${appName} (github.com/${args.repo})`
|
|
78
|
+
}));
|
|
79
|
+
roleArn = created.Role.Arn;
|
|
80
|
+
console.log(`Created role ${roleName} for repo ${args.repo}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- IV APPLY PERMISSIONS ---
|
|
84
|
+
if (!manifest.productId){
|
|
85
|
+
console.log('WARNING: No productId in manifest. Marketplace access is scoped to all products. Re-run set-repo after update-listing -pid to scope it');
|
|
86
|
+
}
|
|
87
|
+
const policy = buildPolicy(accountId, bucket, appPrefix, appName, manifest.productId, derived);
|
|
88
|
+
await iam.send(new PutRolePolicyCommand({
|
|
89
|
+
RoleName: roleName,
|
|
90
|
+
PolicyName: 'mason-app-access',
|
|
91
|
+
PolicyDocument: JSON.stringify(policy)
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
console.log(`Configured role ${roleName} (${roleArn})`);
|
|
95
|
+
console.log(`Use in GitHub Actions with: role-to-assume: ${roleArn}`);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
/////////////////////////////////////////
|
|
101
|
+
////////////// FUNCS ////////////////////
|
|
102
|
+
////////////////////////////////////////
|
|
103
|
+
|
|
104
|
+
function parseCdn(cdn){
|
|
105
|
+
const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
|
|
106
|
+
const parts = p.split('/');
|
|
107
|
+
return { bucket: parts[0], prefix: parts.slice(1).join('/') };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Union the registry schema handler permissions for every resource type in the
|
|
111
|
+
// template, plus property-conditional extras the schemas cannot express
|
|
112
|
+
async function derivePermissions(region, stackText){
|
|
113
|
+
const cf = new CloudFormationClient({ region });
|
|
114
|
+
const summary = await cf.send(new GetTemplateSummaryCommand({ TemplateBody: stackText }));
|
|
115
|
+
const types = [...new Set(summary.ResourceTypes || [])];
|
|
116
|
+
console.log('Template resources: ' + types.join(', '));
|
|
117
|
+
|
|
118
|
+
const actions = new Set();
|
|
119
|
+
for (const t of types){
|
|
120
|
+
try {
|
|
121
|
+
const r = await cf.send(new DescribeTypeCommand({ Type: 'RESOURCE', TypeName: t }));
|
|
122
|
+
const schema = JSON.parse(r.Schema);
|
|
123
|
+
['create', 'read', 'update', 'delete', 'list'].forEach(h=>{
|
|
124
|
+
const perms = schema.handlers && schema.handlers[h] && schema.handlers[h].permissions;
|
|
125
|
+
(perms || []).forEach(p=>{ actions.add(p) });
|
|
126
|
+
});
|
|
127
|
+
} catch (e){
|
|
128
|
+
console.log(`WARNING: No registry schema for ${t} (${e.name}). Add its permissions manually if needed`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Property-conditional permissions the registry schemas do not declare
|
|
133
|
+
if (/InstanceProfile|AWS::IAM::Role|RoleArn/i.test(stackText)){
|
|
134
|
+
actions.add('iam:PassRole');
|
|
135
|
+
actions.add('iam:GetRole');
|
|
136
|
+
}
|
|
137
|
+
if (/kms/i.test(stackText)){
|
|
138
|
+
['kms:DescribeKey', 'kms:CreateGrant', 'kms:Decrypt', 'kms:GenerateDataKey'].forEach(a=>{ actions.add(a) });
|
|
139
|
+
}
|
|
140
|
+
return [...actions].sort();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildPolicy(accountId, bucket, appPrefix, appName, productId, derived){
|
|
144
|
+
const marketplaceEntity = productId
|
|
145
|
+
? `arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/AmiProduct/${productId}`
|
|
146
|
+
: `arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/AmiProduct/*`;
|
|
147
|
+
|
|
148
|
+
const statements = [
|
|
149
|
+
{
|
|
150
|
+
Sid: "AppPrefixWrite",
|
|
151
|
+
Effect: "Allow",
|
|
152
|
+
Action: ["s3:GetObject", "s3:PutObject"],
|
|
153
|
+
Resource: `arn:aws:s3:::${bucket}/${appPrefix}/*`
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
Sid: "AppPrefixList",
|
|
157
|
+
Effect: "Allow",
|
|
158
|
+
Action: "s3:ListBucket",
|
|
159
|
+
Resource: `arn:aws:s3:::${bucket}`,
|
|
160
|
+
Condition: { StringLike: { "s3:prefix": `${appPrefix}/*` } }
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
Sid: "MarketplacePublish",
|
|
164
|
+
Effect: "Allow",
|
|
165
|
+
Action: ["aws-marketplace:StartChangeSet", "aws-marketplace:DescribeChangeSet", "aws-marketplace:DescribeEntity"],
|
|
166
|
+
Resource: [
|
|
167
|
+
marketplaceEntity,
|
|
168
|
+
`arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/ChangeSet/*`
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
Sid: "AppStacks",
|
|
173
|
+
Effect: "Allow",
|
|
174
|
+
Action: ["cloudformation:CreateStack", "cloudformation:UpdateStack", "cloudformation:DescribeStacks"],
|
|
175
|
+
Resource: `arn:aws:cloudformation:*:${accountId}:stack/${appName}-*/*`
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
Sid: "ReadTemplateSummary",
|
|
179
|
+
Effect: "Allow",
|
|
180
|
+
// launch calls GetTemplateSummary with a TemplateURL, which IAM
|
|
181
|
+
// evaluates against * rather than a stack ARN
|
|
182
|
+
Action: "cloudformation:GetTemplateSummary",
|
|
183
|
+
Resource: "*"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
Sid: "ReadRoleParam",
|
|
187
|
+
Effect: "Allow",
|
|
188
|
+
Action: "ssm:GetParameter",
|
|
189
|
+
Resource: `arn:aws:ssm:us-east-1:${accountId}:parameter/mason-cdn-${bucket}`
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
// The SSH AMI build creates untagged temporary resources and
|
|
193
|
+
// prunes images in every region - it cannot be scoped
|
|
194
|
+
Sid: "AmiBuild",
|
|
195
|
+
Effect: "Allow",
|
|
196
|
+
Action: "ec2:*",
|
|
197
|
+
Resource: "*"
|
|
198
|
+
}
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
if (derived.length){
|
|
202
|
+
// ec2 is already covered wholesale by the build statement
|
|
203
|
+
let actions = derived.filter(a=>{ return !a.startsWith('ec2:') });
|
|
204
|
+
let policy = wrap(statements, actions);
|
|
205
|
+
if (JSON.stringify(policy).length > POLICY_CHAR_LIMIT){
|
|
206
|
+
// Collapse to per-service wildcards to stay under the inline policy size cap
|
|
207
|
+
actions = [...new Set(actions.map(a=>{ return a.split(':')[0] + ':*' }))].sort();
|
|
208
|
+
console.log('Derived actions exceed the policy size limit. Collapsed to service wildcards: ' + actions.join(', '));
|
|
209
|
+
policy = wrap(statements, actions);
|
|
210
|
+
}
|
|
211
|
+
return policy;
|
|
212
|
+
}
|
|
213
|
+
return { Version: "2012-10-17", Statement: statements };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function wrap(statements, derivedActions){
|
|
217
|
+
const all = [...statements];
|
|
218
|
+
if (derivedActions.length){
|
|
219
|
+
all.push({
|
|
220
|
+
Sid: "TemplateResources",
|
|
221
|
+
Effect: "Allow",
|
|
222
|
+
Action: derivedActions,
|
|
223
|
+
Resource: "*"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return { Version: "2012-10-17", Statement: all };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function githubTrustPolicy(oidcArn, repo){
|
|
230
|
+
return {
|
|
231
|
+
Version: "2012-10-17",
|
|
232
|
+
Statement: [
|
|
233
|
+
{
|
|
234
|
+
Effect: "Allow",
|
|
235
|
+
Principal: { Federated: oidcArn },
|
|
236
|
+
Action: "sts:AssumeRoleWithWebIdentity",
|
|
237
|
+
Condition: {
|
|
238
|
+
StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
|
|
239
|
+
StringLike: { "token.actions.githubusercontent.com:sub": `repo:${repo}:*` }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function ensureOidcProvider(iam, accountId){
|
|
247
|
+
const arn = `arn:aws:iam::${accountId}:oidc-provider/token.actions.githubusercontent.com`;
|
|
248
|
+
try {
|
|
249
|
+
await iam.send(new GetOpenIDConnectProviderCommand({ OpenIDConnectProviderArn: arn }));
|
|
250
|
+
return arn;
|
|
251
|
+
} catch (e){
|
|
252
|
+
if (!/NoSuchEntity/.test(e)){ throw e }
|
|
253
|
+
}
|
|
254
|
+
await iam.send(new CreateOpenIDConnectProviderCommand({
|
|
255
|
+
Url: 'https://token.actions.githubusercontent.com',
|
|
256
|
+
ClientIDList: ['sts.amazonaws.com'],
|
|
257
|
+
ThumbprintList: ['6938fd4d98bab03faadb97b34396831e3780aea1']
|
|
258
|
+
}));
|
|
259
|
+
console.log('Created GitHub OIDC identity provider');
|
|
260
|
+
return arn;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function getFileText(s3, bucket, key){
|
|
264
|
+
try {
|
|
265
|
+
const r = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
266
|
+
return await r.Body.transformToString();
|
|
267
|
+
} catch (e){
|
|
268
|
+
if (/NoSuchKey|NotFound/.test(e)){ return null }
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
|
|
4
|
-
const { IAMClient, GetRoleCommand, UpdateAssumeRolePolicyCommand } = require("@aws-sdk/client-iam");
|
|
5
|
-
const { STSClient, GetCallerIdentityCommand } = require("@aws-sdk/client-sts");
|
|
6
4
|
const OrgConfig = require('./helpers/org_config');
|
|
7
5
|
const Apps = require('./helpers/apps');
|
|
8
6
|
const Mime = require('./helpers/mime');
|
|
9
|
-
const { githubTrustPolicy } = require('./new_app');
|
|
10
7
|
|
|
11
8
|
// Marketplace listing text files stored as top-level manifest attributes.
|
|
12
9
|
// Names match the keys the AWS Marketplace Catalog API expects
|
|
@@ -30,32 +27,25 @@ exports.main = async function(args){
|
|
|
30
27
|
if (!app){ throw new Error('No app named ' + args.app + '. Run new-app') }
|
|
31
28
|
const { appPrefix, manifest } = app;
|
|
32
29
|
|
|
33
|
-
if (!args.assets && !args.pid && !args.ec2
|
|
34
|
-
console.log('Nothing to update. Pass -assets, -pid,
|
|
30
|
+
if (!args.assets && !args.pid && !args.ec2){
|
|
31
|
+
console.log('Nothing to update. Pass -assets, -pid, or -ec2');
|
|
35
32
|
return true;
|
|
36
33
|
}
|
|
37
34
|
|
|
38
35
|
// Apply all manifest updates in one download/update/upload pass
|
|
39
|
-
if (args.assets
|
|
40
|
-
|
|
41
|
-
await applyAssets(s3, bucket, appPrefix, cfg.region, args.assets, manifest);
|
|
42
|
-
}
|
|
43
|
-
if (args.pid){
|
|
44
|
-
manifest.productId = args.pid;
|
|
45
|
-
console.log('Set productId');
|
|
46
|
-
}
|
|
47
|
-
if (args.ec2){
|
|
48
|
-
manifest.RecommendedInstanceType = args.ec2;
|
|
49
|
-
console.log('Set RecommendedInstanceType');
|
|
50
|
-
}
|
|
51
|
-
await putManifest(s3, bucket, appPrefix, manifest);
|
|
52
|
-
console.log('Updated manifest');
|
|
36
|
+
if (args.assets){
|
|
37
|
+
await applyAssets(s3, bucket, appPrefix, cfg.region, args.assets, manifest);
|
|
53
38
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
if (args.pid){
|
|
40
|
+
manifest.productId = args.pid;
|
|
41
|
+
console.log('Set productId');
|
|
42
|
+
}
|
|
43
|
+
if (args.ec2){
|
|
44
|
+
manifest.RecommendedInstanceType = args.ec2;
|
|
45
|
+
console.log('Set RecommendedInstanceType');
|
|
58
46
|
}
|
|
47
|
+
await putManifest(s3, bucket, appPrefix, manifest);
|
|
48
|
+
console.log('Updated manifest');
|
|
59
49
|
return true;
|
|
60
50
|
}
|
|
61
51
|
|
|
@@ -125,28 +115,6 @@ async function applyAssets(s3, bucket, appPrefix, region, assetsPath, manifest){
|
|
|
125
115
|
}
|
|
126
116
|
}
|
|
127
117
|
|
|
128
|
-
async function updateRepoTrust(appName, repo){
|
|
129
|
-
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)){ throw new Error('Invalid repo. Use org/repo format') }
|
|
130
|
-
const roleName = `mason-gha-${appName}`;
|
|
131
|
-
const iam = new IAMClient({ region: 'us-east-1' });
|
|
132
|
-
const sts = new STSClient({ region: 'us-east-1' });
|
|
133
|
-
|
|
134
|
-
try {
|
|
135
|
-
await iam.send(new GetRoleCommand({ RoleName: roleName }));
|
|
136
|
-
} catch (e){
|
|
137
|
-
if (/NoSuchEntity/.test(e)){ throw new Error(`No CI role ${roleName} found. Run new-app with -repo first`) }
|
|
138
|
-
throw e;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
142
|
-
const oidcArn = `arn:aws:iam::${accountId}:oidc-provider/token.actions.githubusercontent.com`;
|
|
143
|
-
await iam.send(new UpdateAssumeRolePolicyCommand({
|
|
144
|
-
RoleName: roleName,
|
|
145
|
-
PolicyDocument: JSON.stringify(githubTrustPolicy(oidcArn, repo))
|
|
146
|
-
}));
|
|
147
|
-
console.log(`Updated ${roleName} trust policy to repo ${repo}`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
118
|
async function putManifest(s3, bucket, appPrefix, manifest){
|
|
151
119
|
await s3.send(new PutObjectCommand({
|
|
152
120
|
Bucket: bucket,
|
package/main.js
CHANGED
|
@@ -27,7 +27,6 @@ const Commands = {
|
|
|
27
27
|
exec: require('./commands/new_app').main,
|
|
28
28
|
args: [
|
|
29
29
|
{n: 'name', desc: 'Application name (letters only)', pattern: `^[A-Za-z]{2,20}$`, r: true},
|
|
30
|
-
{n: 'repo', desc: 'GitHub repo (org/repo). Creates a CI role assumable by GitHub Actions from that repo', r: false},
|
|
31
30
|
{n: 'noami', desc: 'Serverless app: update-app skips the AMI build and launch passes no AmiId', r: false},
|
|
32
31
|
{n: 'private', desc: 'Store the app under a private _ folder (not publicly accessible)', r: false}
|
|
33
32
|
]
|
|
@@ -39,8 +38,16 @@ const Commands = {
|
|
|
39
38
|
{n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true},
|
|
40
39
|
{n: 'assets', desc: 'Path to folder with application assets', r: false},
|
|
41
40
|
{n: 'pid', desc: 'AWS Product ID', r: false},
|
|
42
|
-
{n: 'ec2', desc: 'Instance type (default r8g.medium)', r: false}
|
|
43
|
-
|
|
41
|
+
{n: 'ec2', desc: 'Instance type (default r8g.medium)', r: false}
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
'set-repo': {
|
|
45
|
+
desc: 'Create/update the GitHub Actions CI role: repo trust + permissions derived from the stack template',
|
|
46
|
+
exec: require('./commands/set_repo').main,
|
|
47
|
+
args: [
|
|
48
|
+
{n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true},
|
|
49
|
+
{n: 'repo', desc: 'GitHub repo (org/repo) allowed to assume the role', r: true},
|
|
50
|
+
{n: 'stack', desc: 'Path to stack template for permission derivation (default: latest version stack in s3)', r: false}
|
|
44
51
|
]
|
|
45
52
|
},
|
|
46
53
|
'update-assets': {
|