@mannyc1/ts-release 0.2.2 → 0.3.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.
Files changed (107) hide show
  1. package/ARCHITECTURE.md +60 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +129 -6
  4. package/SPEC.md +51 -0
  5. package/apps/release-ts/README.md +7 -0
  6. package/apps/ts-release-agents/package.json +1 -1
  7. package/apps/ts-release-agents/src/check.ts +7 -2
  8. package/apps/ts-release-agents/test/contract.test.ts +2 -2
  9. package/dist/bin/ts-release.js +154 -47
  10. package/dist/drivers/archive.d.ts +2 -1
  11. package/dist/drivers/archive.d.ts.map +1 -1
  12. package/dist/drivers/archive.js +12 -8
  13. package/dist/drivers/archive.js.map +1 -1
  14. package/dist/drivers/process.d.ts.map +1 -1
  15. package/dist/drivers/process.js +4 -4
  16. package/dist/drivers/process.js.map +1 -1
  17. package/dist/drivers/python-wheel.d.ts +21 -0
  18. package/dist/drivers/python-wheel.d.ts.map +1 -0
  19. package/dist/drivers/python-wheel.js +109 -0
  20. package/dist/drivers/python-wheel.js.map +1 -0
  21. package/dist/drivers/workspace.d.ts +3 -0
  22. package/dist/drivers/workspace.d.ts.map +1 -1
  23. package/dist/drivers/workspace.js +5 -1
  24. package/dist/drivers/workspace.js.map +1 -1
  25. package/dist/operation-journal/authority.d.ts +3 -0
  26. package/dist/operation-journal/authority.d.ts.map +1 -0
  27. package/dist/operation-journal/authority.js +147 -0
  28. package/dist/operation-journal/authority.js.map +1 -0
  29. package/dist/operation-journal/aws/deadline.d.ts +12 -0
  30. package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
  31. package/dist/operation-journal/aws/deadline.js +38 -0
  32. package/dist/operation-journal/aws/deadline.js.map +1 -0
  33. package/dist/operation-journal/aws/oidc.d.ts +27 -0
  34. package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
  35. package/dist/operation-journal/aws/oidc.js +361 -0
  36. package/dist/operation-journal/aws/oidc.js.map +1 -0
  37. package/dist/operation-journal/aws/policy.d.ts +42 -0
  38. package/dist/operation-journal/aws/policy.d.ts.map +1 -0
  39. package/dist/operation-journal/aws/policy.js +404 -0
  40. package/dist/operation-journal/aws/policy.js.map +1 -0
  41. package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
  42. package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
  43. package/dist/operation-journal/aws/s3-boundary.js +417 -0
  44. package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
  45. package/dist/operation-journal/aws.d.ts +11 -0
  46. package/dist/operation-journal/aws.d.ts.map +1 -0
  47. package/dist/operation-journal/aws.js +50 -0
  48. package/dist/operation-journal/aws.js.map +1 -0
  49. package/dist/operation-journal/canonical.d.ts +52 -0
  50. package/dist/operation-journal/canonical.d.ts.map +1 -0
  51. package/dist/operation-journal/canonical.js +282 -0
  52. package/dist/operation-journal/canonical.js.map +1 -0
  53. package/dist/operation-journal/model.d.ts +251 -0
  54. package/dist/operation-journal/model.d.ts.map +1 -0
  55. package/dist/operation-journal/model.js +56 -0
  56. package/dist/operation-journal/model.js.map +1 -0
  57. package/dist/operation-journal/reducer.d.ts +28 -0
  58. package/dist/operation-journal/reducer.d.ts.map +1 -0
  59. package/dist/operation-journal/reducer.js +73 -0
  60. package/dist/operation-journal/reducer.js.map +1 -0
  61. package/dist/operation-journal/s3.d.ts +7 -0
  62. package/dist/operation-journal/s3.d.ts.map +1 -0
  63. package/dist/operation-journal/s3.js +667 -0
  64. package/dist/operation-journal/s3.js.map +1 -0
  65. package/dist/operation-journal.d.ts +14 -0
  66. package/dist/operation-journal.d.ts.map +1 -0
  67. package/dist/operation-journal.js +12 -0
  68. package/dist/operation-journal.js.map +1 -0
  69. package/dist/platform/credentials.d.ts.map +1 -1
  70. package/dist/platform/credentials.js +65 -3
  71. package/dist/platform/credentials.js.map +1 -1
  72. package/dist/publication/github.d.ts.map +1 -1
  73. package/dist/publication/github.js +66 -15
  74. package/dist/publication/github.js.map +1 -1
  75. package/dist/release/capabilities.d.ts.map +1 -1
  76. package/dist/release/capabilities.js +2 -2
  77. package/dist/release/capabilities.js.map +1 -1
  78. package/dist/release/prepare.d.ts.map +1 -1
  79. package/dist/release/prepare.js +4 -5
  80. package/dist/release/prepare.js.map +1 -1
  81. package/dist/release/prepared-store.d.ts.map +1 -1
  82. package/dist/release/prepared-store.js +11 -9
  83. package/dist/release/prepared-store.js.map +1 -1
  84. package/package.json +19 -4
  85. package/src/drivers/archive.ts +17 -8
  86. package/src/drivers/process.ts +3 -4
  87. package/src/drivers/python-wheel.ts +145 -0
  88. package/src/drivers/workspace.ts +7 -1
  89. package/src/operation-journal/authority.ts +162 -0
  90. package/src/operation-journal/aws/deadline.ts +41 -0
  91. package/src/operation-journal/aws/oidc.ts +439 -0
  92. package/src/operation-journal/aws/policy.ts +491 -0
  93. package/src/operation-journal/aws/s3-boundary.ts +562 -0
  94. package/src/operation-journal/aws.ts +60 -0
  95. package/src/operation-journal/canonical.ts +373 -0
  96. package/src/operation-journal/model.ts +301 -0
  97. package/src/operation-journal/reducer.ts +112 -0
  98. package/src/operation-journal/s3.ts +884 -0
  99. package/src/operation-journal.ts +51 -0
  100. package/src/platform/credentials.ts +71 -3
  101. package/src/publication/github.ts +83 -15
  102. package/src/release/capabilities.ts +3 -2
  103. package/src/release/prepare.ts +3 -5
  104. package/src/release/prepared-store.ts +8 -7
  105. package/templates/README.md +2 -2
  106. package/templates/github-actions/release.yml +24 -7
  107. package/templates/github-actions/reviewed-release.yml +49 -14
@@ -0,0 +1,491 @@
1
+ import { encodeCanonicalJson, parseStrictJson, type Json } from "../../model/canonical.js"
2
+ import { objectChecksum } from "../canonical.js"
3
+ import {
4
+ S3JournalBoundaryError,
5
+ type S3JournalAuthority,
6
+ type S3JournalOidcTrustConditions
7
+ } from "../model.js"
8
+
9
+ const encoder = new TextEncoder()
10
+
11
+ type JsonObject = { readonly [key: string]: Json }
12
+
13
+ interface NormalizedPrincipal {
14
+ readonly AWS?: ReadonlyArray<string>
15
+ readonly Federated?: ReadonlyArray<string>
16
+ }
17
+
18
+ interface NormalizedStatement {
19
+ readonly Sid: string
20
+ readonly Effect: "Allow" | "Deny"
21
+ readonly Principal?: "*" | NormalizedPrincipal
22
+ readonly Action: ReadonlyArray<string>
23
+ readonly Resource?: ReadonlyArray<string>
24
+ readonly NotResource?: ReadonlyArray<string>
25
+ readonly Condition?: Readonly<Record<string, Readonly<Record<string, ReadonlyArray<string>>>>>
26
+ }
27
+
28
+ interface NormalizedPolicy {
29
+ readonly Version: "2012-10-17"
30
+ readonly Statement: ReadonlyArray<NormalizedStatement>
31
+ }
32
+
33
+ export interface AwsJournalPolicyCoordinates {
34
+ readonly accountId: string
35
+ readonly bucketArn: string
36
+ readonly roleArn: string
37
+ readonly rolePolicyName: string
38
+ readonly oidcTrust: S3JournalOidcTrustConditions
39
+ }
40
+
41
+ export interface AwsJournalObservedPolicyDigests {
42
+ readonly bucketPolicyDigest: string
43
+ readonly rolePolicyDigest: string
44
+ readonly oidcTrustPolicyDigest: string
45
+ }
46
+
47
+ const boundaryFailure = (reason: string): S3JournalBoundaryError =>
48
+ S3JournalBoundaryError.make({
49
+ operation: "observe-authority",
50
+ commitment: "not-applicable",
51
+ reason
52
+ })
53
+
54
+ const fail = (reason: string): never => {
55
+ throw boundaryFailure(reason)
56
+ }
57
+
58
+ const isObject = (value: Json): value is JsonObject =>
59
+ typeof value === "object" && value !== null && !Array.isArray(value)
60
+
61
+ const exactObject = (
62
+ value: Json,
63
+ fields: ReadonlyArray<string>,
64
+ label: string
65
+ ): JsonObject => {
66
+ if (!isObject(value)) fail(`${label} must be an object.`)
67
+ const object = value as JsonObject
68
+ const actual = Object.keys(object).sort()
69
+ const expected = [...fields].sort()
70
+ if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) {
71
+ fail(`${label} fields are not the exact admitted set.`)
72
+ }
73
+ return object
74
+ }
75
+
76
+ const stringValue = (value: Json | undefined, label: string): string => {
77
+ if (typeof value !== "string" || value.length === 0 || value !== value.normalize("NFC")) {
78
+ fail(`${label} must be one non-empty NFC string.`)
79
+ }
80
+ return value as string
81
+ }
82
+
83
+ const stringSet = (value: Json | undefined, label: string): ReadonlyArray<string> => {
84
+ const values = Array.isArray(value)
85
+ ? value.map((item, index) => stringValue(item, `${label}[${index}]`))
86
+ : [stringValue(value, label)]
87
+ const sorted = [...values].sort()
88
+ if (sorted.length === 0 || new Set(sorted).size !== sorted.length) {
89
+ fail(`${label} must be one duplicate-free string set.`)
90
+ }
91
+ return sorted
92
+ }
93
+
94
+ const normalizePrincipal = (value: Json | undefined, label: string): "*" | NormalizedPrincipal => {
95
+ if (value === "*") return "*"
96
+ if (!isObject(value!)) fail(`${label} must be '*' or an exact principal object.`)
97
+ const principal = value as JsonObject
98
+ const fields = Object.keys(principal).sort()
99
+ if (fields.length !== 1 || (fields[0] !== "AWS" && fields[0] !== "Federated")) {
100
+ fail(`${label} must contain exactly one AWS or Federated principal kind.`)
101
+ }
102
+ const field = fields[0] as "AWS" | "Federated"
103
+ return { [field]: stringSet(principal[field], `${label}.${field}`) }
104
+ }
105
+
106
+ const normalizeCondition = (
107
+ value: Json | undefined,
108
+ label: string
109
+ ): Readonly<Record<string, Readonly<Record<string, ReadonlyArray<string>>>>> => {
110
+ if (!isObject(value!)) fail(`${label} must be an object.`)
111
+ const condition = value as JsonObject
112
+ const normalized: Record<string, Record<string, ReadonlyArray<string>>> = {}
113
+ const operators = Object.keys(condition).sort()
114
+ if (operators.length === 0) fail(`${label} must not be empty.`)
115
+ for (const operator of operators) {
116
+ if (!/^[A-Za-z][A-Za-z0-9:]*$/u.test(operator)) fail(`${label} has an invalid operator.`)
117
+ const entries = condition[operator]
118
+ if (!isObject(entries!)) fail(`${label}.${operator} must be an object.`)
119
+ const normalizedEntries: Record<string, ReadonlyArray<string>> = {}
120
+ const keys = Object.keys(entries as JsonObject).sort()
121
+ if (keys.length === 0) fail(`${label}.${operator} must not be empty.`)
122
+ for (const key of keys) {
123
+ normalizedEntries[key] = stringSet((entries as JsonObject)[key], `${label}.${operator}.${key}`)
124
+ }
125
+ normalized[operator] = normalizedEntries
126
+ }
127
+ return normalized
128
+ }
129
+
130
+ const normalizeStatement = (value: Json, index: number): NormalizedStatement => {
131
+ if (!isObject(value)) fail(`Policy statement ${index} must be an object.`)
132
+ const object = value as JsonObject
133
+ const admitted = new Set(["Sid", "Effect", "Principal", "Action", "Resource", "NotResource", "Condition"])
134
+ for (const field of Object.keys(object)) {
135
+ if (!admitted.has(field)) fail(`Policy statement ${index} contains unadmitted field ${field}.`)
136
+ }
137
+ const sid = stringValue(object.Sid, `Policy statement ${index}.Sid`)
138
+ if (!/^[A-Za-z0-9]{1,128}$/u.test(sid)) fail(`Policy statement ${index}.Sid is not canonical.`)
139
+ const effect = stringValue(object.Effect, `Policy statement ${index}.Effect`)
140
+ if (effect !== "Allow" && effect !== "Deny") fail(`Policy statement ${index}.Effect is not admitted.`)
141
+ if (object.Resource !== undefined && object.NotResource !== undefined) {
142
+ fail(`Policy statement ${index} cannot contain both Resource and NotResource.`)
143
+ }
144
+ return {
145
+ Sid: sid,
146
+ Effect: effect as "Allow" | "Deny",
147
+ ...(object.Principal === undefined ? {} : { Principal: normalizePrincipal(object.Principal, `Policy statement ${index}.Principal`) }),
148
+ Action: stringSet(object.Action, `Policy statement ${index}.Action`),
149
+ ...(object.Resource === undefined ? {} : { Resource: stringSet(object.Resource, `Policy statement ${index}.Resource`) }),
150
+ ...(object.NotResource === undefined ? {} : { NotResource: stringSet(object.NotResource, `Policy statement ${index}.NotResource`) }),
151
+ ...(object.Condition === undefined ? {} : { Condition: normalizeCondition(object.Condition, `Policy statement ${index}.Condition`) })
152
+ }
153
+ }
154
+
155
+ const parsePolicyDocument = (document: string, label: string): NormalizedPolicy => {
156
+ let parsed: Json
157
+ try {
158
+ const source = document.trimStart().startsWith("{") ? document : decodeURIComponent(document)
159
+ parsed = parseStrictJson(source)
160
+ } catch {
161
+ return fail(`${label} is not one valid JSON or RFC3986-encoded JSON policy.`)
162
+ }
163
+ const policy = exactObject(parsed, ["Statement", "Version"], label)
164
+ if (policy.Version !== "2012-10-17") fail(`${label} has an unsupported version.`)
165
+ const rawStatements = Array.isArray(policy.Statement) ? policy.Statement : [policy.Statement!]
166
+ if (rawStatements.length === 0) fail(`${label} has no statements.`)
167
+ const statements = rawStatements.map(normalizeStatement).sort((left, right) => left.Sid.localeCompare(right.Sid))
168
+ if (new Set(statements.map((statement) => statement.Sid)).size !== statements.length) {
169
+ fail(`${label} repeats a statement Sid.`)
170
+ }
171
+ return { Version: "2012-10-17", Statement: statements }
172
+ }
173
+
174
+ const canonicalPolicy = (policy: NormalizedPolicy): string => encodeCanonicalJson(policy)
175
+ const policyDigest = (policy: NormalizedPolicy): string => objectChecksum(encoder.encode(canonicalPolicy(policy)))
176
+
177
+ const condition = (
178
+ operator: string,
179
+ entries: Readonly<Record<string, string | ReadonlyArray<string>>>
180
+ ): Readonly<Record<string, Readonly<Record<string, ReadonlyArray<string>>>>> => ({
181
+ [operator]: Object.fromEntries(Object.entries(entries).map(([key, value]) => [
182
+ key,
183
+ [...(Array.isArray(value) ? value : [value])].sort()
184
+ ]))
185
+ })
186
+
187
+ const statement = (value: NormalizedStatement): NormalizedStatement => value
188
+
189
+ const requireImmutableCalledWorkflow = (coordinates: AwsJournalPolicyCoordinates): void => {
190
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml@[a-f0-9]{40}$/u
191
+ .test(coordinates.oidcTrust.jobWorkflowRef)) {
192
+ fail("Journal OIDC trust must pin the called workflow to one immutable source SHA.")
193
+ }
194
+ }
195
+
196
+ export const makeAwsJournalTrustPolicy = (
197
+ coordinates: AwsJournalPolicyCoordinates
198
+ ): NormalizedPolicy => {
199
+ requireImmutableCalledWorkflow(coordinates)
200
+ return {
201
+ Version: "2012-10-17",
202
+ Statement: [statement({
203
+ Sid: "AllowGitHubJournalOidc",
204
+ Effect: "Allow",
205
+ Principal: {
206
+ Federated: [`arn:aws:iam::${coordinates.accountId}:oidc-provider/token.actions.githubusercontent.com`]
207
+ },
208
+ Action: ["sts:AssumeRoleWithWebIdentity"],
209
+ Condition: condition("StringEquals", {
210
+ "token.actions.githubusercontent.com:aud": coordinates.oidcTrust.audience,
211
+ "token.actions.githubusercontent.com:sub": coordinates.oidcTrust.subject,
212
+ "token.actions.githubusercontent.com:repository": coordinates.oidcTrust.repository,
213
+ "token.actions.githubusercontent.com:repository_id": coordinates.oidcTrust.repositoryId,
214
+ "token.actions.githubusercontent.com:repository_owner_id": coordinates.oidcTrust.repositoryOwnerId,
215
+ "token.actions.githubusercontent.com:workflow": coordinates.oidcTrust.workflow,
216
+ "token.actions.githubusercontent.com:ref": coordinates.oidcTrust.ref,
217
+ "token.actions.githubusercontent.com:environment": coordinates.oidcTrust.environment,
218
+ "token.actions.githubusercontent.com:job_workflow_ref": coordinates.oidcTrust.jobWorkflowRef
219
+ })
220
+ })]
221
+ }
222
+ }
223
+
224
+ export const makeAwsJournalRolePolicy = (
225
+ coordinates: AwsJournalPolicyCoordinates
226
+ ): NormalizedPolicy => ({
227
+ Version: "2012-10-17",
228
+ Statement: [
229
+ statement({
230
+ Sid: "DenyJournalBucketGovernanceMutation",
231
+ Effect: "Deny",
232
+ Action: [
233
+ "s3:DeleteBucket*",
234
+ "s3:DeletePublicAccessBlock",
235
+ "s3:PutBucket*",
236
+ "s3:PutLifecycleConfiguration",
237
+ "s3:PutPublicAccessBlock"
238
+ ].sort(),
239
+ Resource: [coordinates.bucketArn]
240
+ }),
241
+ statement({
242
+ Sid: "DenyJournalCopy",
243
+ Effect: "Deny",
244
+ Action: ["s3:PutObject"],
245
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
246
+ Condition: condition("Null", { "s3:x-amz-copy-source": "false" })
247
+ }),
248
+ statement({
249
+ Sid: "DenyJournalDestructiveObjectMutation",
250
+ Effect: "Deny",
251
+ Action: [
252
+ "s3:AbortMultipartUpload",
253
+ "s3:BypassGovernanceRetention",
254
+ "s3:DeleteObject",
255
+ "s3:DeleteObjectVersion",
256
+ "s3:PutObjectAcl",
257
+ "s3:PutObjectLegalHold",
258
+ "s3:PutObjectRetention",
259
+ "s3:PutObjectTagging"
260
+ ].sort(),
261
+ Resource: [`${coordinates.bucketArn}/*`]
262
+ }),
263
+ statement({
264
+ Sid: "DenyJournalIamMutation",
265
+ Effect: "Deny",
266
+ Action: [
267
+ "iam:Add*",
268
+ "iam:Attach*",
269
+ "iam:Change*",
270
+ "iam:Create*",
271
+ "iam:Deactivate*",
272
+ "iam:Delete*",
273
+ "iam:Detach*",
274
+ "iam:Enable*",
275
+ "iam:PassRole",
276
+ "iam:Put*",
277
+ "iam:Remove*",
278
+ "iam:Reset*",
279
+ "iam:Resync*",
280
+ "iam:Set*",
281
+ "iam:Tag*",
282
+ "iam:Untag*",
283
+ "iam:Update*",
284
+ "iam:Upload*"
285
+ ].sort(),
286
+ Resource: ["*"]
287
+ }),
288
+ statement({
289
+ Sid: "DenyJournalMultipart",
290
+ Effect: "Deny",
291
+ Action: ["s3:PutObject"],
292
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
293
+ Condition: condition("Bool", { "s3:ObjectCreationOperation": "false" })
294
+ }),
295
+ statement({
296
+ Sid: "DenyJournalWritesOutsideNamespace",
297
+ Effect: "Deny",
298
+ Action: ["s3:PutObject"],
299
+ NotResource: [`${coordinates.bucketArn}/operation-journal/v1/*`]
300
+ }),
301
+ statement({
302
+ Sid: "ListJournalNamespace",
303
+ Effect: "Allow",
304
+ Action: ["s3:ListBucketVersions"],
305
+ Resource: [coordinates.bucketArn],
306
+ Condition: condition("StringLike", { "s3:prefix": "operation-journal/v1/*" })
307
+ }),
308
+ statement({
309
+ Sid: "ReadJournalBucketGovernance",
310
+ Effect: "Allow",
311
+ Action: [
312
+ "s3:GetBucketObjectLockConfiguration",
313
+ "s3:GetBucketOwnershipControls",
314
+ "s3:GetBucketPolicy",
315
+ "s3:GetBucketPolicyStatus",
316
+ "s3:GetBucketPublicAccessBlock",
317
+ "s3:GetBucketVersioning"
318
+ ].sort(),
319
+ Resource: [coordinates.bucketArn]
320
+ }),
321
+ statement({
322
+ Sid: "ReadJournalRole",
323
+ Effect: "Allow",
324
+ Action: [
325
+ "iam:GetRole",
326
+ "iam:GetRolePolicy",
327
+ "iam:ListAttachedRolePolicies",
328
+ "iam:ListRolePolicies"
329
+ ].sort(),
330
+ Resource: [coordinates.roleArn]
331
+ }),
332
+ statement({
333
+ Sid: "ReadWriteJournalObjects",
334
+ Effect: "Allow",
335
+ Action: [
336
+ "s3:GetObjectRetention",
337
+ "s3:GetObjectVersion",
338
+ "s3:GetObjectVersionAttributes",
339
+ "s3:PutObject"
340
+ ].sort(),
341
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`]
342
+ })
343
+ ].sort((left, right) => left.Sid.localeCompare(right.Sid))
344
+ })
345
+
346
+ export const makeAwsJournalBucketPolicy = (
347
+ coordinates: AwsJournalPolicyCoordinates
348
+ ): NormalizedPolicy => ({
349
+ Version: "2012-10-17",
350
+ Statement: [
351
+ statement({
352
+ Sid: "DenyJournalCopy",
353
+ Effect: "Deny",
354
+ Principal: "*",
355
+ Action: ["s3:PutObject"],
356
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
357
+ Condition: condition("Null", { "s3:x-amz-copy-source": "false" })
358
+ }),
359
+ statement({
360
+ Sid: "DenyJournalDeletes",
361
+ Effect: "Deny",
362
+ Principal: "*",
363
+ Action: ["s3:DeleteObject", "s3:DeleteObjectVersion"],
364
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`]
365
+ }),
366
+ statement({
367
+ Sid: "DenyJournalDualConditionWrite",
368
+ Effect: "Deny",
369
+ Principal: "*",
370
+ Action: ["s3:PutObject"],
371
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
372
+ Condition: {
373
+ Bool: { "s3:ObjectCreationOperation": ["true"] },
374
+ Null: { "s3:if-match": ["false"], "s3:if-none-match": ["false"] }
375
+ }
376
+ }),
377
+ statement({
378
+ Sid: "DenyJournalMetadataMutation",
379
+ Effect: "Deny",
380
+ Principal: "*",
381
+ Action: [
382
+ "s3:PutObjectAcl",
383
+ "s3:PutObjectLegalHold",
384
+ "s3:PutObjectRetention",
385
+ "s3:PutObjectTagging"
386
+ ].sort(),
387
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`]
388
+ }),
389
+ statement({
390
+ Sid: "DenyJournalMultipart",
391
+ Effect: "Deny",
392
+ Principal: "*",
393
+ Action: ["s3:PutObject"],
394
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
395
+ Condition: condition("Bool", { "s3:ObjectCreationOperation": "false" })
396
+ }),
397
+ statement({
398
+ Sid: "DenyJournalRoleWriteOutsideNamespace",
399
+ Effect: "Deny",
400
+ Principal: "*",
401
+ Action: ["s3:PutObject"],
402
+ NotResource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
403
+ Condition: condition("ArnEquals", { "aws:PrincipalArn": coordinates.roleArn })
404
+ }),
405
+ statement({
406
+ Sid: "DenyJournalUnconditionalWrite",
407
+ Effect: "Deny",
408
+ Principal: "*",
409
+ Action: ["s3:PutObject"],
410
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
411
+ Condition: {
412
+ Bool: { "s3:ObjectCreationOperation": ["true"] },
413
+ Null: { "s3:if-match": ["true"], "s3:if-none-match": ["true"] }
414
+ }
415
+ }),
416
+ statement({
417
+ Sid: "DenyNonJournalPrincipalList",
418
+ Effect: "Deny",
419
+ Principal: "*",
420
+ Action: ["s3:ListBucketVersions"],
421
+ Resource: [coordinates.bucketArn],
422
+ Condition: condition("ArnNotEquals", { "aws:PrincipalArn": coordinates.roleArn })
423
+ }),
424
+ statement({
425
+ Sid: "DenyNonJournalPrincipalObjectAccess",
426
+ Effect: "Deny",
427
+ Principal: "*",
428
+ Action: [
429
+ "s3:GetObject",
430
+ "s3:GetObjectAttributes",
431
+ "s3:GetObjectRetention",
432
+ "s3:GetObjectVersion",
433
+ "s3:GetObjectVersionAttributes",
434
+ "s3:PutObject"
435
+ ].sort(),
436
+ Resource: [`${coordinates.bucketArn}/operation-journal/v1/*`],
437
+ Condition: condition("ArnNotEquals", { "aws:PrincipalArn": coordinates.roleArn })
438
+ })
439
+ ].sort((left, right) => left.Sid.localeCompare(right.Sid))
440
+ })
441
+
442
+ const requireExactPolicy = (
443
+ observedDocument: string,
444
+ expected: NormalizedPolicy,
445
+ label: string
446
+ ): NormalizedPolicy => {
447
+ const observed = parsePolicyDocument(observedDocument, label)
448
+ if (canonicalPolicy(observed) !== canonicalPolicy(expected)) {
449
+ fail(`${label} does not equal the one admitted operational-journal policy.`)
450
+ }
451
+ return observed
452
+ }
453
+
454
+ export const validateAwsJournalPolicies = (input: {
455
+ readonly coordinates: AwsJournalPolicyCoordinates
456
+ readonly trustPolicyDocument: string
457
+ readonly rolePolicyDocument: string
458
+ readonly bucketPolicyDocument: string
459
+ }): AwsJournalObservedPolicyDigests => {
460
+ const trust = requireExactPolicy(
461
+ input.trustPolicyDocument,
462
+ makeAwsJournalTrustPolicy(input.coordinates),
463
+ "Journal role trust policy"
464
+ )
465
+ const role = requireExactPolicy(
466
+ input.rolePolicyDocument,
467
+ makeAwsJournalRolePolicy(input.coordinates),
468
+ "Journal role permission policy"
469
+ )
470
+ const bucket = requireExactPolicy(
471
+ input.bucketPolicyDocument,
472
+ makeAwsJournalBucketPolicy(input.coordinates),
473
+ "Journal bucket policy"
474
+ )
475
+ return {
476
+ bucketPolicyDigest: policyDigest(bucket),
477
+ rolePolicyDigest: policyDigest(role),
478
+ oidcTrustPolicyDigest: policyDigest(trust)
479
+ }
480
+ }
481
+
482
+ export const assertAwsJournalPolicyDigests = (
483
+ authority: S3JournalAuthority,
484
+ observed: AwsJournalObservedPolicyDigests
485
+ ): void => {
486
+ if (authority.bucketPolicyDigest !== observed.bucketPolicyDigest ||
487
+ authority.rolePolicyDigest !== observed.rolePolicyDigest ||
488
+ authority.oidcTrustPolicyDigest !== observed.oidcTrustPolicyDigest) {
489
+ fail("Observed journal policy digests drifted from the exact activation contract.")
490
+ }
491
+ }