@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.
- package/ARCHITECTURE.md +60 -0
- package/CHANGELOG.md +82 -0
- package/README.md +129 -6
- package/SPEC.md +51 -0
- package/apps/release-ts/README.md +7 -0
- package/apps/ts-release-agents/package.json +1 -1
- package/apps/ts-release-agents/src/check.ts +7 -2
- package/apps/ts-release-agents/test/contract.test.ts +2 -2
- package/dist/bin/ts-release.js +154 -47
- package/dist/drivers/archive.d.ts +2 -1
- package/dist/drivers/archive.d.ts.map +1 -1
- package/dist/drivers/archive.js +12 -8
- package/dist/drivers/archive.js.map +1 -1
- package/dist/drivers/process.d.ts.map +1 -1
- package/dist/drivers/process.js +4 -4
- package/dist/drivers/process.js.map +1 -1
- package/dist/drivers/python-wheel.d.ts +21 -0
- package/dist/drivers/python-wheel.d.ts.map +1 -0
- package/dist/drivers/python-wheel.js +109 -0
- package/dist/drivers/python-wheel.js.map +1 -0
- package/dist/drivers/workspace.d.ts +3 -0
- package/dist/drivers/workspace.d.ts.map +1 -1
- package/dist/drivers/workspace.js +5 -1
- package/dist/drivers/workspace.js.map +1 -1
- package/dist/operation-journal/authority.d.ts +3 -0
- package/dist/operation-journal/authority.d.ts.map +1 -0
- package/dist/operation-journal/authority.js +147 -0
- package/dist/operation-journal/authority.js.map +1 -0
- package/dist/operation-journal/aws/deadline.d.ts +12 -0
- package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
- package/dist/operation-journal/aws/deadline.js +38 -0
- package/dist/operation-journal/aws/deadline.js.map +1 -0
- package/dist/operation-journal/aws/oidc.d.ts +27 -0
- package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
- package/dist/operation-journal/aws/oidc.js +361 -0
- package/dist/operation-journal/aws/oidc.js.map +1 -0
- package/dist/operation-journal/aws/policy.d.ts +42 -0
- package/dist/operation-journal/aws/policy.d.ts.map +1 -0
- package/dist/operation-journal/aws/policy.js +404 -0
- package/dist/operation-journal/aws/policy.js.map +1 -0
- package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
- package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
- package/dist/operation-journal/aws/s3-boundary.js +417 -0
- package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
- package/dist/operation-journal/aws.d.ts +11 -0
- package/dist/operation-journal/aws.d.ts.map +1 -0
- package/dist/operation-journal/aws.js +50 -0
- package/dist/operation-journal/aws.js.map +1 -0
- package/dist/operation-journal/canonical.d.ts +52 -0
- package/dist/operation-journal/canonical.d.ts.map +1 -0
- package/dist/operation-journal/canonical.js +282 -0
- package/dist/operation-journal/canonical.js.map +1 -0
- package/dist/operation-journal/model.d.ts +251 -0
- package/dist/operation-journal/model.d.ts.map +1 -0
- package/dist/operation-journal/model.js +56 -0
- package/dist/operation-journal/model.js.map +1 -0
- package/dist/operation-journal/reducer.d.ts +28 -0
- package/dist/operation-journal/reducer.d.ts.map +1 -0
- package/dist/operation-journal/reducer.js +73 -0
- package/dist/operation-journal/reducer.js.map +1 -0
- package/dist/operation-journal/s3.d.ts +7 -0
- package/dist/operation-journal/s3.d.ts.map +1 -0
- package/dist/operation-journal/s3.js +667 -0
- package/dist/operation-journal/s3.js.map +1 -0
- package/dist/operation-journal.d.ts +14 -0
- package/dist/operation-journal.d.ts.map +1 -0
- package/dist/operation-journal.js +12 -0
- package/dist/operation-journal.js.map +1 -0
- package/dist/platform/credentials.d.ts.map +1 -1
- package/dist/platform/credentials.js +65 -3
- package/dist/platform/credentials.js.map +1 -1
- package/dist/publication/github.d.ts.map +1 -1
- package/dist/publication/github.js +66 -15
- package/dist/publication/github.js.map +1 -1
- package/dist/release/capabilities.d.ts.map +1 -1
- package/dist/release/capabilities.js +2 -2
- package/dist/release/capabilities.js.map +1 -1
- package/dist/release/prepare.d.ts.map +1 -1
- package/dist/release/prepare.js +4 -5
- package/dist/release/prepare.js.map +1 -1
- package/dist/release/prepared-store.d.ts.map +1 -1
- package/dist/release/prepared-store.js +11 -9
- package/dist/release/prepared-store.js.map +1 -1
- package/package.json +19 -4
- package/src/drivers/archive.ts +17 -8
- package/src/drivers/process.ts +3 -4
- package/src/drivers/python-wheel.ts +145 -0
- package/src/drivers/workspace.ts +7 -1
- package/src/operation-journal/authority.ts +162 -0
- package/src/operation-journal/aws/deadline.ts +41 -0
- package/src/operation-journal/aws/oidc.ts +439 -0
- package/src/operation-journal/aws/policy.ts +491 -0
- package/src/operation-journal/aws/s3-boundary.ts +562 -0
- package/src/operation-journal/aws.ts +60 -0
- package/src/operation-journal/canonical.ts +373 -0
- package/src/operation-journal/model.ts +301 -0
- package/src/operation-journal/reducer.ts +112 -0
- package/src/operation-journal/s3.ts +884 -0
- package/src/operation-journal.ts +51 -0
- package/src/platform/credentials.ts +71 -3
- package/src/publication/github.ts +83 -15
- package/src/release/capabilities.ts +3 -2
- package/src/release/prepare.ts +3 -5
- package/src/release/prepared-store.ts +8 -7
- package/templates/README.md +2 -2
- package/templates/github-actions/release.yml +24 -7
- package/templates/github-actions/reviewed-release.yml +49 -14
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer"
|
|
2
|
+
import {
|
|
3
|
+
GetRoleCommand,
|
|
4
|
+
GetRolePolicyCommand,
|
|
5
|
+
IAMClient,
|
|
6
|
+
ListAttachedRolePoliciesCommand,
|
|
7
|
+
ListRolePoliciesCommand
|
|
8
|
+
} from "@aws-sdk/client-iam"
|
|
9
|
+
import {
|
|
10
|
+
GetBucketOwnershipControlsCommand,
|
|
11
|
+
GetBucketPolicyCommand,
|
|
12
|
+
GetBucketPolicyStatusCommand,
|
|
13
|
+
GetBucketVersioningCommand,
|
|
14
|
+
GetObjectAttributesCommand,
|
|
15
|
+
GetObjectCommand,
|
|
16
|
+
GetObjectLockConfigurationCommand,
|
|
17
|
+
GetObjectRetentionCommand,
|
|
18
|
+
GetPublicAccessBlockCommand,
|
|
19
|
+
ListObjectVersionsCommand,
|
|
20
|
+
PutObjectCommand,
|
|
21
|
+
S3Client
|
|
22
|
+
} from "@aws-sdk/client-s3"
|
|
23
|
+
import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"
|
|
24
|
+
import * as Effect from "effect/Effect"
|
|
25
|
+
import { isCanonicalS3VersionId, objectChecksum } from "../canonical.js"
|
|
26
|
+
import {
|
|
27
|
+
S3JournalBoundaryError,
|
|
28
|
+
operationJournalByteLimits,
|
|
29
|
+
type S3JournalAuthority,
|
|
30
|
+
type S3JournalBoundaryShape,
|
|
31
|
+
type S3JournalNamespaceListing,
|
|
32
|
+
type S3JournalObjectVersion,
|
|
33
|
+
type S3JournalPutRequest,
|
|
34
|
+
type S3JournalPutResult
|
|
35
|
+
} from "../model.js"
|
|
36
|
+
import type { AwsJournalSession } from "./oidc.js"
|
|
37
|
+
import {
|
|
38
|
+
operationJournalNetworkDeadlineMilliseconds,
|
|
39
|
+
withOperationJournalNetworkDeadline
|
|
40
|
+
} from "./deadline.js"
|
|
41
|
+
import {
|
|
42
|
+
assertAwsJournalPolicyDigests,
|
|
43
|
+
validateAwsJournalPolicies,
|
|
44
|
+
type AwsJournalPolicyCoordinates
|
|
45
|
+
} from "./policy.js"
|
|
46
|
+
|
|
47
|
+
export interface AwsS3JournalBoundaryOptions {
|
|
48
|
+
readonly authority: S3JournalAuthority
|
|
49
|
+
readonly rolePolicyName: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AwsJournalClients {
|
|
53
|
+
readonly iam: IAMClient
|
|
54
|
+
readonly s3: S3Client
|
|
55
|
+
readonly sts: STSClient
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const error = (
|
|
59
|
+
operation: S3JournalBoundaryError["operation"],
|
|
60
|
+
commitment: S3JournalBoundaryError["commitment"],
|
|
61
|
+
reason: string
|
|
62
|
+
): S3JournalBoundaryError => S3JournalBoundaryError.make({ operation, commitment, reason })
|
|
63
|
+
|
|
64
|
+
const fail = (
|
|
65
|
+
operation: S3JournalBoundaryError["operation"],
|
|
66
|
+
reason: string
|
|
67
|
+
): never => {
|
|
68
|
+
throw error(operation, "not-applicable", reason)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const boundaryError = (
|
|
72
|
+
cause: unknown,
|
|
73
|
+
operation: S3JournalBoundaryError["operation"],
|
|
74
|
+
reason: string
|
|
75
|
+
): S3JournalBoundaryError => cause instanceof S3JournalBoundaryError
|
|
76
|
+
? cause
|
|
77
|
+
: error(operation, "not-applicable", reason)
|
|
78
|
+
|
|
79
|
+
const statusCode = (cause: unknown): number | undefined => {
|
|
80
|
+
if (typeof cause !== "object" || cause === null || !("$metadata" in cause)) return undefined
|
|
81
|
+
const metadata = (cause as { readonly $metadata?: { readonly httpStatusCode?: unknown } }).$metadata
|
|
82
|
+
return typeof metadata?.httpStatusCode === "number" ? metadata.httpStatusCode : undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const errorName = (cause: unknown): string => {
|
|
86
|
+
if (typeof cause !== "object" || cause === null || !("name" in cause) ||
|
|
87
|
+
typeof (cause as { readonly name?: unknown }).name !== "string") return "UnknownError"
|
|
88
|
+
const name = (cause as { readonly name: string }).name
|
|
89
|
+
return /^[A-Za-z0-9_.-]{1,128}$/u.test(name) ? name : "UnknownError"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const putFailure = (cause: unknown, operation: "put-event" | "put-head"): S3JournalBoundaryError => {
|
|
93
|
+
if (cause instanceof S3JournalBoundaryError) return cause
|
|
94
|
+
const status = statusCode(cause)
|
|
95
|
+
return error(
|
|
96
|
+
operation,
|
|
97
|
+
status !== undefined && status >= 300 && status < 500 ? "not-committed" : "unknown",
|
|
98
|
+
`AWS S3 conditional write did not return one admitted response (${errorName(cause)}).`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const roleName = (roleArn: string): string => roleArn.slice(roleArn.lastIndexOf("/") + 1)
|
|
103
|
+
|
|
104
|
+
const journalNamespacePattern = /^operation-journal\/v1\/[a-f0-9]{40}\/[a-f0-9]{64}\/$/u
|
|
105
|
+
const journalEventKeyPattern = /^operation-journal\/v1\/[a-f0-9]{40}\/[a-f0-9]{64}\/events\/[0-9]{8}\/[a-f0-9-]{36}\.bin$/u
|
|
106
|
+
const journalHeadKeyPattern = /^operation-journal\/v1\/[a-f0-9]{40}\/[a-f0-9]{64}\/head\.bin$/u
|
|
107
|
+
|
|
108
|
+
const requireSealedInput = (
|
|
109
|
+
input: { readonly bucketName: string, readonly expectedBucketOwner: string, readonly region: string },
|
|
110
|
+
authority: S3JournalAuthority,
|
|
111
|
+
operation: S3JournalBoundaryError["operation"]
|
|
112
|
+
): void => {
|
|
113
|
+
if (input.bucketName !== authority.bucketName ||
|
|
114
|
+
input.expectedBucketOwner !== authority.expectedBucketOwner ||
|
|
115
|
+
input.region !== authority.region) {
|
|
116
|
+
fail(operation, "AWS journal request coordinates escaped the sealed activation contract.")
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const requireString = (
|
|
121
|
+
value: string | undefined,
|
|
122
|
+
operation: S3JournalBoundaryError["operation"],
|
|
123
|
+
label: string
|
|
124
|
+
): string => {
|
|
125
|
+
if (value === undefined || value.length === 0) fail(operation, `${label} is absent.`)
|
|
126
|
+
return value as string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const checksumToBase64 = (checksum: string, operation: "put-event" | "put-head"): string => {
|
|
130
|
+
if (!/^sha256:[a-f0-9]{64}$/u.test(checksum)) fail(operation, "Journal checksum is not canonical SHA-256.")
|
|
131
|
+
return Buffer.from(checksum.slice("sha256:".length), "hex").toString("base64")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const checksumFromBase64 = (
|
|
135
|
+
checksum: string | undefined,
|
|
136
|
+
operation: S3JournalBoundaryError["operation"]
|
|
137
|
+
): string => {
|
|
138
|
+
if (checksum === undefined || !/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/u.test(checksum)) {
|
|
139
|
+
fail(operation, "AWS S3 response lacks one canonical SHA-256 checksum.")
|
|
140
|
+
}
|
|
141
|
+
const canonicalChecksum = checksum as string
|
|
142
|
+
const bytes = Buffer.from(canonicalChecksum, "base64")
|
|
143
|
+
if (bytes.length !== 32 || bytes.toString("base64") !== canonicalChecksum) {
|
|
144
|
+
fail(operation, "AWS S3 response SHA-256 checksum is malformed.")
|
|
145
|
+
}
|
|
146
|
+
return `sha256:${bytes.toString("hex")}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const readBoundedObjectBody = async (
|
|
150
|
+
body: unknown,
|
|
151
|
+
contentLength: number,
|
|
152
|
+
deadlineMilliseconds = operationJournalNetworkDeadlineMilliseconds
|
|
153
|
+
): Promise<Uint8Array> => {
|
|
154
|
+
if (!Number.isSafeInteger(contentLength) || contentLength < 1 ||
|
|
155
|
+
contentLength > operationJournalByteLimits.object) {
|
|
156
|
+
fail("get-object-version", "AWS S3 exact object Content-Length exceeds the admitted byte bound.")
|
|
157
|
+
}
|
|
158
|
+
if (typeof body !== "object" || body === null ||
|
|
159
|
+
typeof (body as { readonly [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !== "function") {
|
|
160
|
+
fail("get-object-version", "AWS S3 exact object body is not one bounded byte stream.")
|
|
161
|
+
}
|
|
162
|
+
const stream = body as AsyncIterable<unknown>
|
|
163
|
+
const iterator = stream[Symbol.asyncIterator]()
|
|
164
|
+
return withOperationJournalNetworkDeadline(async (signal) => {
|
|
165
|
+
const stop = (): void => {
|
|
166
|
+
try {
|
|
167
|
+
const destroy = (body as { readonly destroy?: (cause?: unknown) => void }).destroy
|
|
168
|
+
if (typeof destroy === "function") destroy.call(body, signal.reason)
|
|
169
|
+
} catch {
|
|
170
|
+
// The deadline result is authoritative; cleanup failures are not a fallback result.
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const completion = iterator.return?.()
|
|
174
|
+
if (completion !== undefined) void Promise.resolve(completion).catch(() => undefined)
|
|
175
|
+
} catch {
|
|
176
|
+
// The deadline result is authoritative; cleanup failures are not a fallback result.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
signal.addEventListener("abort", stop, { once: true })
|
|
180
|
+
const bytes = new Uint8Array(contentLength)
|
|
181
|
+
let offset = 0
|
|
182
|
+
try {
|
|
183
|
+
while (true) {
|
|
184
|
+
const result = await iterator.next()
|
|
185
|
+
if (result.done === true) break
|
|
186
|
+
const byteChunk = result.value instanceof Uint8Array
|
|
187
|
+
? result.value
|
|
188
|
+
: fail("get-object-version", "AWS S3 exact object stream emitted a non-byte chunk.")
|
|
189
|
+
if (offset + byteChunk.length > bytes.length) {
|
|
190
|
+
fail("get-object-version", "AWS S3 exact object stream exceeded its admitted Content-Length.")
|
|
191
|
+
}
|
|
192
|
+
bytes.set(byteChunk, offset)
|
|
193
|
+
offset += byteChunk.length
|
|
194
|
+
}
|
|
195
|
+
if (offset !== bytes.length) {
|
|
196
|
+
fail("get-object-version", "AWS S3 exact object stream length disagrees with Content-Length.")
|
|
197
|
+
}
|
|
198
|
+
return bytes
|
|
199
|
+
} finally {
|
|
200
|
+
signal.removeEventListener("abort", stop)
|
|
201
|
+
}
|
|
202
|
+
}, deadlineMilliseconds)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const makePolicyCoordinates = (options: AwsS3JournalBoundaryOptions): AwsJournalPolicyCoordinates => ({
|
|
206
|
+
accountId: options.authority.accountId,
|
|
207
|
+
bucketArn: options.authority.bucketArn,
|
|
208
|
+
roleArn: options.authority.roleArn,
|
|
209
|
+
rolePolicyName: options.rolePolicyName,
|
|
210
|
+
oidcTrust: options.authority.oidcTrust
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const observeAuthority = (
|
|
214
|
+
options: AwsS3JournalBoundaryOptions,
|
|
215
|
+
session: AwsJournalSession,
|
|
216
|
+
clients: AwsJournalClients,
|
|
217
|
+
deadlineMilliseconds: number
|
|
218
|
+
): Effect.Effect<S3JournalAuthority, S3JournalBoundaryError> => Effect.tryPromise({
|
|
219
|
+
try: async () => {
|
|
220
|
+
const authority = options.authority
|
|
221
|
+
if (!/^[A-Za-z0-9+=,.@_-]{1,128}$/u.test(options.rolePolicyName)) {
|
|
222
|
+
fail("observe-authority", "Journal inline role policy name is not canonical.")
|
|
223
|
+
}
|
|
224
|
+
const name = roleName(authority.roleArn)
|
|
225
|
+
const [
|
|
226
|
+
roleResponse,
|
|
227
|
+
inlineResponse,
|
|
228
|
+
attachedResponse,
|
|
229
|
+
versioningResponse,
|
|
230
|
+
lockResponse,
|
|
231
|
+
ownershipResponse,
|
|
232
|
+
publicAccessResponse,
|
|
233
|
+
bucketPolicyResponse,
|
|
234
|
+
bucketPolicyStatusResponse,
|
|
235
|
+
callerIdentityResponse
|
|
236
|
+
] = await Promise.all([
|
|
237
|
+
withOperationJournalNetworkDeadline((signal) => clients.iam.send(
|
|
238
|
+
new GetRoleCommand({ RoleName: name }), { abortSignal: signal }
|
|
239
|
+
), deadlineMilliseconds),
|
|
240
|
+
withOperationJournalNetworkDeadline((signal) => clients.iam.send(
|
|
241
|
+
new ListRolePoliciesCommand({ RoleName: name, MaxItems: 2 }), { abortSignal: signal }
|
|
242
|
+
), deadlineMilliseconds),
|
|
243
|
+
withOperationJournalNetworkDeadline((signal) => clients.iam.send(
|
|
244
|
+
new ListAttachedRolePoliciesCommand({ RoleName: name, MaxItems: 1 }), { abortSignal: signal }
|
|
245
|
+
), deadlineMilliseconds),
|
|
246
|
+
withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketVersioningCommand({
|
|
247
|
+
Bucket: authority.bucketName,
|
|
248
|
+
ExpectedBucketOwner: authority.expectedBucketOwner
|
|
249
|
+
}), { abortSignal: signal }), deadlineMilliseconds),
|
|
250
|
+
withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetObjectLockConfigurationCommand({
|
|
251
|
+
Bucket: authority.bucketName,
|
|
252
|
+
ExpectedBucketOwner: authority.expectedBucketOwner
|
|
253
|
+
}), { abortSignal: signal }), deadlineMilliseconds),
|
|
254
|
+
withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketOwnershipControlsCommand({
|
|
255
|
+
Bucket: authority.bucketName,
|
|
256
|
+
ExpectedBucketOwner: authority.expectedBucketOwner
|
|
257
|
+
}), { abortSignal: signal }), deadlineMilliseconds),
|
|
258
|
+
withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetPublicAccessBlockCommand({
|
|
259
|
+
Bucket: authority.bucketName,
|
|
260
|
+
ExpectedBucketOwner: authority.expectedBucketOwner
|
|
261
|
+
}), { abortSignal: signal }), deadlineMilliseconds),
|
|
262
|
+
withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketPolicyCommand({
|
|
263
|
+
Bucket: authority.bucketName,
|
|
264
|
+
ExpectedBucketOwner: authority.expectedBucketOwner
|
|
265
|
+
}), { abortSignal: signal }), deadlineMilliseconds),
|
|
266
|
+
withOperationJournalNetworkDeadline((signal) => clients.s3.send(new GetBucketPolicyStatusCommand({
|
|
267
|
+
Bucket: authority.bucketName,
|
|
268
|
+
ExpectedBucketOwner: authority.expectedBucketOwner
|
|
269
|
+
}), { abortSignal: signal }), deadlineMilliseconds),
|
|
270
|
+
withOperationJournalNetworkDeadline((signal) => clients.sts.send(
|
|
271
|
+
new GetCallerIdentityCommand({}), { abortSignal: signal }
|
|
272
|
+
), deadlineMilliseconds)
|
|
273
|
+
])
|
|
274
|
+
const role = roleResponse.Role
|
|
275
|
+
if (role?.Arn !== authority.roleArn || role.RoleName !== name ||
|
|
276
|
+
role.AssumeRolePolicyDocument === undefined || role.PermissionsBoundary !== undefined ||
|
|
277
|
+
role.MaxSessionDuration === undefined || role.MaxSessionDuration < 900) {
|
|
278
|
+
fail("observe-authority", "AWS journal role identity or session governance drifted.")
|
|
279
|
+
}
|
|
280
|
+
if (inlineResponse.IsTruncated === true || inlineResponse.PolicyNames?.length !== 1 ||
|
|
281
|
+
inlineResponse.PolicyNames[0] !== options.rolePolicyName ||
|
|
282
|
+
attachedResponse.IsTruncated === true || (attachedResponse.AttachedPolicies?.length ?? 0) !== 0) {
|
|
283
|
+
fail("observe-authority", "AWS journal role does not contain exactly one inline policy and zero managed policies.")
|
|
284
|
+
}
|
|
285
|
+
const rolePolicyResponse = await withOperationJournalNetworkDeadline((signal) => clients.iam.send(
|
|
286
|
+
new GetRolePolicyCommand({
|
|
287
|
+
RoleName: name,
|
|
288
|
+
PolicyName: options.rolePolicyName
|
|
289
|
+
}),
|
|
290
|
+
{ abortSignal: signal }
|
|
291
|
+
), deadlineMilliseconds)
|
|
292
|
+
if (rolePolicyResponse.RoleName !== name || rolePolicyResponse.PolicyName !== options.rolePolicyName ||
|
|
293
|
+
rolePolicyResponse.PolicyDocument === undefined) {
|
|
294
|
+
fail("observe-authority", "AWS journal inline role policy response is incomplete.")
|
|
295
|
+
}
|
|
296
|
+
if (versioningResponse.Status !== "Enabled") {
|
|
297
|
+
fail("observe-authority", "AWS journal bucket versioning is not enabled.")
|
|
298
|
+
}
|
|
299
|
+
const retention = lockResponse.ObjectLockConfiguration?.Rule?.DefaultRetention
|
|
300
|
+
if (lockResponse.ObjectLockConfiguration?.ObjectLockEnabled !== "Enabled" ||
|
|
301
|
+
retention?.Mode !== "COMPLIANCE" || retention.Years !== 10 || retention.Days !== undefined) {
|
|
302
|
+
fail("observe-authority", "AWS journal bucket Object Lock is not ten-year COMPLIANCE retention.")
|
|
303
|
+
}
|
|
304
|
+
const ownershipRules = ownershipResponse.OwnershipControls?.Rules
|
|
305
|
+
if (ownershipRules?.length !== 1 || ownershipRules[0]?.ObjectOwnership !== "BucketOwnerEnforced") {
|
|
306
|
+
fail("observe-authority", "AWS journal bucket ownership is not BucketOwnerEnforced.")
|
|
307
|
+
}
|
|
308
|
+
const publicBlock = publicAccessResponse.PublicAccessBlockConfiguration
|
|
309
|
+
if (publicBlock?.BlockPublicAcls !== true || publicBlock.IgnorePublicAcls !== true ||
|
|
310
|
+
publicBlock.BlockPublicPolicy !== true || publicBlock.RestrictPublicBuckets !== true ||
|
|
311
|
+
bucketPolicyStatusResponse.PolicyStatus?.IsPublic !== false) {
|
|
312
|
+
fail("observe-authority", "AWS journal bucket public access is not completely blocked.")
|
|
313
|
+
}
|
|
314
|
+
if (callerIdentityResponse.Account !== authority.accountId ||
|
|
315
|
+
callerIdentityResponse.Arn !== session.callerArn ||
|
|
316
|
+
callerIdentityResponse.UserId === undefined || callerIdentityResponse.UserId.length === 0) {
|
|
317
|
+
fail("observe-authority", "AWS STS caller identity drifted from the exact journal role session.")
|
|
318
|
+
}
|
|
319
|
+
const policyDigests = validateAwsJournalPolicies({
|
|
320
|
+
coordinates: makePolicyCoordinates(options),
|
|
321
|
+
trustPolicyDocument: role!.AssumeRolePolicyDocument as string,
|
|
322
|
+
rolePolicyDocument: rolePolicyResponse.PolicyDocument as string,
|
|
323
|
+
bucketPolicyDocument: requireString(bucketPolicyResponse.Policy, "observe-authority", "AWS journal bucket policy")
|
|
324
|
+
})
|
|
325
|
+
assertAwsJournalPolicyDigests(authority, policyDigests)
|
|
326
|
+
const oidcTrust = {
|
|
327
|
+
audience: session.oidc.audience,
|
|
328
|
+
subject: session.oidc.subject,
|
|
329
|
+
repository: session.oidc.repository,
|
|
330
|
+
repositoryId: session.oidc.repositoryId,
|
|
331
|
+
repositoryOwnerId: session.oidc.repositoryOwnerId,
|
|
332
|
+
workflow: session.oidc.workflow,
|
|
333
|
+
ref: session.oidc.ref,
|
|
334
|
+
environment: session.oidc.environment,
|
|
335
|
+
jobWorkflowRef: session.oidc.jobWorkflowRef
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
accountId: authority.accountId,
|
|
339
|
+
bucketName: authority.bucketName,
|
|
340
|
+
bucketArn: authority.bucketArn,
|
|
341
|
+
region: authority.region,
|
|
342
|
+
roleArn: authority.roleArn,
|
|
343
|
+
prefix: "operation-journal/v1",
|
|
344
|
+
expectedBucketOwner: authority.expectedBucketOwner,
|
|
345
|
+
versioning: "Enabled",
|
|
346
|
+
objectLock: "Enabled",
|
|
347
|
+
retentionMode: "COMPLIANCE",
|
|
348
|
+
retentionYears: 10,
|
|
349
|
+
bucketOwnerEnforced: true,
|
|
350
|
+
publicAccessBlocked: true,
|
|
351
|
+
deleteDenied: true,
|
|
352
|
+
multipartDenied: true,
|
|
353
|
+
conditionalWritesEnforced: true,
|
|
354
|
+
...policyDigests,
|
|
355
|
+
oidc: session.oidc,
|
|
356
|
+
oidcTrust
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
catch: (cause) => boundaryError(cause, "observe-authority", "AWS journal authority observation failed.")
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
const listNamespace = (
|
|
363
|
+
options: AwsS3JournalBoundaryOptions,
|
|
364
|
+
client: S3Client,
|
|
365
|
+
input: Parameters<S3JournalBoundaryShape["listNamespace"]>[0],
|
|
366
|
+
deadlineMilliseconds: number
|
|
367
|
+
): Effect.Effect<S3JournalNamespaceListing, S3JournalBoundaryError> => Effect.tryPromise({
|
|
368
|
+
try: async () => {
|
|
369
|
+
requireSealedInput(input, options.authority, "list-namespace")
|
|
370
|
+
if (!journalNamespacePattern.test(input.prefix) || input.maximum !== 512) {
|
|
371
|
+
fail("list-namespace", "AWS journal namespace read escaped its exact bound.")
|
|
372
|
+
}
|
|
373
|
+
const response = await withOperationJournalNetworkDeadline((signal) => client.send(
|
|
374
|
+
new ListObjectVersionsCommand({
|
|
375
|
+
Bucket: input.bucketName,
|
|
376
|
+
Prefix: input.prefix,
|
|
377
|
+
MaxKeys: input.maximum,
|
|
378
|
+
ExpectedBucketOwner: input.expectedBucketOwner
|
|
379
|
+
}),
|
|
380
|
+
{ abortSignal: signal }
|
|
381
|
+
), deadlineMilliseconds)
|
|
382
|
+
if (response.Name !== input.bucketName || response.Prefix !== input.prefix ||
|
|
383
|
+
response.MaxKeys !== input.maximum || typeof response.IsTruncated !== "boolean") {
|
|
384
|
+
fail("list-namespace", "AWS S3 version listing identity or bound drifted from the exact request.")
|
|
385
|
+
}
|
|
386
|
+
if ((response.Versions?.length ?? 0) + (response.DeleteMarkers?.length ?? 0) > input.maximum) {
|
|
387
|
+
fail("list-namespace", "AWS S3 version listing exceeded its exact response bound.")
|
|
388
|
+
}
|
|
389
|
+
const versions = (response.Versions ?? []).map((version, index) => {
|
|
390
|
+
const key = requireString(version.Key, "list-namespace", `AWS S3 version ${index} key`)
|
|
391
|
+
const versionId = requireString(version.VersionId, "list-namespace", `AWS S3 version ${index} VersionId`)
|
|
392
|
+
const etag = requireString(version.ETag, "list-namespace", `AWS S3 version ${index} ETag`)
|
|
393
|
+
if ((!journalEventKeyPattern.test(key) && !journalHeadKeyPattern.test(key)) ||
|
|
394
|
+
!isCanonicalS3VersionId(versionId) || !/^"[!#-~]{1,1024}"$/u.test(etag)) {
|
|
395
|
+
fail("list-namespace", `AWS S3 version ${index} identity is not canonical.`)
|
|
396
|
+
}
|
|
397
|
+
return { key, versionId, etag, isLatest: version.IsLatest === true }
|
|
398
|
+
})
|
|
399
|
+
const deleteMarkers = (response.DeleteMarkers ?? []).map((marker, index) => {
|
|
400
|
+
const key = requireString(marker.Key, "list-namespace", `AWS S3 delete marker ${index} key`)
|
|
401
|
+
const versionId = requireString(marker.VersionId, "list-namespace", `AWS S3 delete marker ${index} VersionId`)
|
|
402
|
+
if ((!journalEventKeyPattern.test(key) && !journalHeadKeyPattern.test(key)) ||
|
|
403
|
+
!isCanonicalS3VersionId(versionId)) {
|
|
404
|
+
fail("list-namespace", `AWS S3 delete marker ${index} identity is not canonical.`)
|
|
405
|
+
}
|
|
406
|
+
return { key, versionId }
|
|
407
|
+
})
|
|
408
|
+
if (versions.some((version) => !version.key.startsWith(input.prefix)) ||
|
|
409
|
+
deleteMarkers.some((marker) => !marker.key.startsWith(input.prefix))) {
|
|
410
|
+
fail("list-namespace", "AWS S3 returned an object outside the exact journal prefix.")
|
|
411
|
+
}
|
|
412
|
+
return { versions, deleteMarkers, truncated: response.IsTruncated === true }
|
|
413
|
+
},
|
|
414
|
+
catch: (cause) => boundaryError(cause, "list-namespace", `AWS S3 journal namespace read failed (${errorName(cause)}).`)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
const getObjectVersion = (
|
|
418
|
+
options: AwsS3JournalBoundaryOptions,
|
|
419
|
+
client: S3Client,
|
|
420
|
+
input: Parameters<S3JournalBoundaryShape["getObjectVersion"]>[0],
|
|
421
|
+
deadlineMilliseconds: number
|
|
422
|
+
): Effect.Effect<S3JournalObjectVersion, S3JournalBoundaryError> => Effect.tryPromise({
|
|
423
|
+
try: async () => {
|
|
424
|
+
requireSealedInput(input, options.authority, "get-object-version")
|
|
425
|
+
if ((!journalEventKeyPattern.test(input.key) && !journalHeadKeyPattern.test(input.key)) ||
|
|
426
|
+
!isCanonicalS3VersionId(input.versionId)) {
|
|
427
|
+
fail("get-object-version", "AWS journal exact-version read escaped its sealed namespace.")
|
|
428
|
+
}
|
|
429
|
+
const response = await withOperationJournalNetworkDeadline((signal) => client.send(
|
|
430
|
+
new GetObjectCommand({
|
|
431
|
+
Bucket: input.bucketName,
|
|
432
|
+
Key: input.key,
|
|
433
|
+
VersionId: input.versionId,
|
|
434
|
+
ExpectedBucketOwner: input.expectedBucketOwner,
|
|
435
|
+
ChecksumMode: "ENABLED"
|
|
436
|
+
}),
|
|
437
|
+
{ abortSignal: signal }
|
|
438
|
+
), deadlineMilliseconds)
|
|
439
|
+
if (response.Body === undefined || response.ContentLength === undefined || response.LastModified === undefined ||
|
|
440
|
+
response.ObjectLockMode !== "COMPLIANCE" || response.ObjectLockRetainUntilDate === undefined ||
|
|
441
|
+
(response.ChecksumType !== undefined && response.ChecksumType !== "FULL_OBJECT")) {
|
|
442
|
+
fail("get-object-version", "AWS S3 exact object response lacks bytes, time, checksum, or COMPLIANCE retention.")
|
|
443
|
+
}
|
|
444
|
+
const retainUntil = response.ObjectLockRetainUntilDate as Date
|
|
445
|
+
const body = response.Body!
|
|
446
|
+
const lastModified = response.LastModified as Date
|
|
447
|
+
const retention = await withOperationJournalNetworkDeadline((signal) => client.send(
|
|
448
|
+
new GetObjectRetentionCommand({
|
|
449
|
+
Bucket: input.bucketName,
|
|
450
|
+
Key: input.key,
|
|
451
|
+
VersionId: input.versionId,
|
|
452
|
+
ExpectedBucketOwner: input.expectedBucketOwner
|
|
453
|
+
}),
|
|
454
|
+
{ abortSignal: signal }
|
|
455
|
+
), deadlineMilliseconds)
|
|
456
|
+
if (retention.Retention?.Mode !== "COMPLIANCE" || retention.Retention.RetainUntilDate === undefined ||
|
|
457
|
+
retention.Retention.RetainUntilDate.getTime() !== retainUntil.getTime()) {
|
|
458
|
+
fail("get-object-version", "AWS S3 exact object retention disagrees with its independent retention read.")
|
|
459
|
+
}
|
|
460
|
+
const bytes = await readBoundedObjectBody(body, response.ContentLength as number, deadlineMilliseconds)
|
|
461
|
+
const attributes = await withOperationJournalNetworkDeadline((signal) => client.send(
|
|
462
|
+
new GetObjectAttributesCommand({
|
|
463
|
+
Bucket: input.bucketName,
|
|
464
|
+
Key: input.key,
|
|
465
|
+
VersionId: input.versionId,
|
|
466
|
+
ExpectedBucketOwner: input.expectedBucketOwner,
|
|
467
|
+
ObjectAttributes: ["Checksum", "ETag", "ObjectSize"]
|
|
468
|
+
}),
|
|
469
|
+
{ abortSignal: signal }
|
|
470
|
+
), deadlineMilliseconds)
|
|
471
|
+
const responseVersionId = requireString(response.VersionId, "get-object-version", "AWS S3 exact object VersionId")
|
|
472
|
+
const checksumSha256 = checksumFromBase64(response.ChecksumSHA256, "get-object-version")
|
|
473
|
+
if (responseVersionId !== input.versionId || attributes.DeleteMarker === true || attributes.VersionId !== input.versionId ||
|
|
474
|
+
attributes.ETag !== response.ETag || attributes.ObjectSize !== bytes.length ||
|
|
475
|
+
attributes.Checksum?.ChecksumType !== "FULL_OBJECT" ||
|
|
476
|
+
checksumFromBase64(attributes.Checksum.ChecksumSHA256, "get-object-version") !== checksumSha256) {
|
|
477
|
+
fail("get-object-version", "AWS S3 exact object attributes disagree with its retained bytes and version.")
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
key: input.key,
|
|
481
|
+
versionId: responseVersionId,
|
|
482
|
+
etag: requireString(response.ETag, "get-object-version", "AWS S3 exact object ETag"),
|
|
483
|
+
checksumSha256,
|
|
484
|
+
bytes,
|
|
485
|
+
lastModified: lastModified.toISOString(),
|
|
486
|
+
retentionMode: "COMPLIANCE",
|
|
487
|
+
retainUntil: retainUntil.toISOString()
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
catch: (cause) => boundaryError(cause, "get-object-version", `AWS S3 exact journal object read failed (${errorName(cause)}).`)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
const putObject = (
|
|
494
|
+
options: AwsS3JournalBoundaryOptions,
|
|
495
|
+
client: S3Client,
|
|
496
|
+
input: S3JournalPutRequest,
|
|
497
|
+
operation: "put-event" | "put-head",
|
|
498
|
+
deadlineMilliseconds: number
|
|
499
|
+
): Effect.Effect<S3JournalPutResult, S3JournalBoundaryError> => Effect.tryPromise({
|
|
500
|
+
try: async () => {
|
|
501
|
+
requireSealedInput(input, options.authority, operation)
|
|
502
|
+
if (operation === "put-event" ? !journalEventKeyPattern.test(input.key) : !journalHeadKeyPattern.test(input.key)) {
|
|
503
|
+
fail(operation, "AWS S3 conditional write key is outside its exact journal object class.")
|
|
504
|
+
}
|
|
505
|
+
if (!(input.bytes instanceof Uint8Array) || input.bytes.length < 1 ||
|
|
506
|
+
input.bytes.length > operationJournalByteLimits.object) {
|
|
507
|
+
fail(operation, "AWS S3 conditional write bytes exceed the admitted object bound.")
|
|
508
|
+
}
|
|
509
|
+
if (objectChecksum(input.bytes) !== input.checksumSha256) {
|
|
510
|
+
fail(operation, "AWS S3 conditional write checksum does not match its exact bytes.")
|
|
511
|
+
}
|
|
512
|
+
if (input.condition._tag === "IfMatch" && !/^"[!#-~]{1,1024}"$/u.test(input.condition.etag)) {
|
|
513
|
+
fail(operation, "AWS S3 If-Match ETag is not canonical.")
|
|
514
|
+
}
|
|
515
|
+
const checksum = checksumToBase64(input.checksumSha256, operation)
|
|
516
|
+
let response
|
|
517
|
+
try {
|
|
518
|
+
response = await withOperationJournalNetworkDeadline((signal) => client.send(
|
|
519
|
+
new PutObjectCommand({
|
|
520
|
+
Bucket: input.bucketName,
|
|
521
|
+
Key: input.key,
|
|
522
|
+
Body: new Uint8Array(input.bytes),
|
|
523
|
+
ContentLength: input.bytes.length,
|
|
524
|
+
ContentType: "application/octet-stream",
|
|
525
|
+
ChecksumAlgorithm: "SHA256",
|
|
526
|
+
ChecksumSHA256: checksum,
|
|
527
|
+
ExpectedBucketOwner: input.expectedBucketOwner,
|
|
528
|
+
...(input.condition._tag === "IfNoneMatch"
|
|
529
|
+
? { IfNoneMatch: "*" }
|
|
530
|
+
: { IfMatch: input.condition.etag })
|
|
531
|
+
}),
|
|
532
|
+
{ abortSignal: signal }
|
|
533
|
+
), deadlineMilliseconds)
|
|
534
|
+
} catch (cause) {
|
|
535
|
+
const status = statusCode(cause)
|
|
536
|
+
if (status === 409 || status === 412) return { _tag: "PreconditionFailed" }
|
|
537
|
+
throw cause
|
|
538
|
+
}
|
|
539
|
+
const versionId = requireString(response.VersionId, operation, "AWS S3 committed object VersionId")
|
|
540
|
+
const etag = requireString(response.ETag, operation, "AWS S3 committed object ETag")
|
|
541
|
+
const responseChecksum = checksumFromBase64(response.ChecksumSHA256, operation)
|
|
542
|
+
if (responseChecksum !== input.checksumSha256 ||
|
|
543
|
+
(response.ChecksumType !== undefined && response.ChecksumType !== "FULL_OBJECT")) {
|
|
544
|
+
fail(operation, "AWS S3 committed object checksum disagrees with the exact request bytes.")
|
|
545
|
+
}
|
|
546
|
+
return { _tag: "Committed", versionId, etag, checksumSha256: responseChecksum }
|
|
547
|
+
},
|
|
548
|
+
catch: (cause) => putFailure(cause, operation)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
export const makeAwsS3JournalBoundaryFromClients = (
|
|
552
|
+
options: AwsS3JournalBoundaryOptions,
|
|
553
|
+
session: AwsJournalSession,
|
|
554
|
+
clients: AwsJournalClients,
|
|
555
|
+
deadlineMilliseconds = operationJournalNetworkDeadlineMilliseconds
|
|
556
|
+
): S3JournalBoundaryShape => ({
|
|
557
|
+
observeAuthority: () => observeAuthority(options, session, clients, deadlineMilliseconds),
|
|
558
|
+
listNamespace: (input) => listNamespace(options, clients.s3, input, deadlineMilliseconds),
|
|
559
|
+
getObjectVersion: (input) => getObjectVersion(options, clients.s3, input, deadlineMilliseconds),
|
|
560
|
+
putEvent: (input) => putObject(options, clients.s3, input, "put-event", deadlineMilliseconds),
|
|
561
|
+
putHead: (input) => putObject(options, clients.s3, input, "put-head", deadlineMilliseconds)
|
|
562
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { IAMClient } from "@aws-sdk/client-iam"
|
|
2
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
3
|
+
import { STSClient } from "@aws-sdk/client-sts"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import {
|
|
6
|
+
S3JournalBoundaryError,
|
|
7
|
+
type S3JournalBoundaryShape
|
|
8
|
+
} from "./model.js"
|
|
9
|
+
import { validateS3JournalAuthority } from "./authority.js"
|
|
10
|
+
import { acquireAwsJournalSession } from "./aws/oidc.js"
|
|
11
|
+
import {
|
|
12
|
+
makeAwsS3JournalBoundaryFromClients,
|
|
13
|
+
type AwsS3JournalBoundaryOptions
|
|
14
|
+
} from "./aws/s3-boundary.js"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Acquire one fresh GitHub OIDC/STS session and seal an AWS S3 boundary around
|
|
18
|
+
* the exact activation contract. The constructor never consults the AWS
|
|
19
|
+
* credential chain and exposes no endpoint, profile, or credential override.
|
|
20
|
+
*/
|
|
21
|
+
export const makeAwsS3JournalBoundary = (
|
|
22
|
+
options: AwsS3JournalBoundaryOptions
|
|
23
|
+
): Effect.Effect<S3JournalBoundaryShape, S3JournalBoundaryError> => Effect.gen(function*() {
|
|
24
|
+
yield* Effect.try({
|
|
25
|
+
try: () => {
|
|
26
|
+
validateS3JournalAuthority(options.authority)
|
|
27
|
+
if (!/^[A-Za-z0-9+=,.@_-]{1,128}$/u.test(options.rolePolicyName)) {
|
|
28
|
+
throw new Error("invalid role policy name")
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
catch: () => S3JournalBoundaryError.make({
|
|
32
|
+
operation: "observe-authority",
|
|
33
|
+
commitment: "not-applicable",
|
|
34
|
+
reason: "AWS journal activation contract is malformed."
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
const session = yield* acquireAwsJournalSession(options.authority)
|
|
38
|
+
const credentials = session.credentials
|
|
39
|
+
const iam = new IAMClient({
|
|
40
|
+
region: options.authority.region,
|
|
41
|
+
credentials,
|
|
42
|
+
maxAttempts: 1
|
|
43
|
+
})
|
|
44
|
+
const s3 = new S3Client({
|
|
45
|
+
region: options.authority.region,
|
|
46
|
+
credentials,
|
|
47
|
+
maxAttempts: 1,
|
|
48
|
+
followRegionRedirects: false,
|
|
49
|
+
forcePathStyle: false,
|
|
50
|
+
useArnRegion: false
|
|
51
|
+
})
|
|
52
|
+
const sts = new STSClient({
|
|
53
|
+
region: options.authority.region,
|
|
54
|
+
credentials,
|
|
55
|
+
maxAttempts: 1
|
|
56
|
+
})
|
|
57
|
+
return makeAwsS3JournalBoundaryFromClients(options, session, { iam, s3, sts })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
export type { AwsS3JournalBoundaryOptions } from "./aws/s3-boundary.js"
|