diff --git a/packages/alchemy/src/AWS/S3/BindingHttp.ts b/packages/alchemy/src/AWS/S3/BindingHttp.ts index 3764d55ce9..5ecc262b09 100644 --- a/packages/alchemy/src/AWS/S3/BindingHttp.ts +++ b/packages/alchemy/src/AWS/S3/BindingHttp.ts @@ -44,7 +44,8 @@ export const makeBucketHttpBinding = < * Additionally grant `s3:ListBucket` on the bucket ARN. Required by * object-read actions so a missing key surfaces as `NoSuchKey`/`NotFound` * (404) instead of `AccessDenied` (403). - * @see https://repost.aws/articles/ARe3OTZ3SCTWWqGtiJ6aHn8Q/why-does-s-3-return-403-instead-of-404-when-the-object-doesnt-exist + * @see https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-with-s3-policy-actions.html */ listBucket?: boolean; }) => diff --git a/packages/alchemy/src/AWS/S3/HeadObject.ts b/packages/alchemy/src/AWS/S3/HeadObject.ts index 3bb6f692be..0072256c9c 100644 --- a/packages/alchemy/src/AWS/S3/HeadObject.ts +++ b/packages/alchemy/src/AWS/S3/HeadObject.ts @@ -14,6 +14,14 @@ export interface HeadObjectRequest extends Omit< * Bind this operation to a bucket to get a callable that reads an object's * metadata (size, content type, ETag) without downloading the body. Provide * the implementation with `Effect.provide(AWS.S3.HeadObjectHttp)`. + * + * The HTTP implementation grants `s3:GetObject` for current-object reads and + * `s3:GetObjectVersion` because this request also supports `versionId`. It + * additionally grants `s3:ListBucket` so a missing key produces AWS's + * documented `404`/`403` distinction instead of always being reported as + * `AccessDenied`. See the [HeadObject API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html) + * and [S3 required permissions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-with-s3-policy-actions.html). + * * ### Inspecting Objects * **Example:** Check an Object's Metadata * ```typescript @@ -26,6 +34,9 @@ export interface HeadObjectRequest extends Omit< * const contentType = head.ContentType; * ``` * + * @see https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-with-s3-policy-actions.html + * * @binding */ export interface HeadObject extends Binding.Service< diff --git a/packages/alchemy/src/AWS/S3/HeadObjectHttp.ts b/packages/alchemy/src/AWS/S3/HeadObjectHttp.ts index 604c49a795..9189e8309b 100644 --- a/packages/alchemy/src/AWS/S3/HeadObjectHttp.ts +++ b/packages/alchemy/src/AWS/S3/HeadObjectHttp.ts @@ -8,6 +8,7 @@ export const HeadObjectHttp = Layer.effect( makeBucketHttpBinding({ tag: "AWS.S3.HeadObject", operation: S3.headObject, - actions: ["s3:GetObject"], + actions: ["s3:GetObject", "s3:GetObjectVersion"], + listBucket: true, }), ); diff --git a/packages/alchemy/src/AWS/S3/PresignGetObject.ts b/packages/alchemy/src/AWS/S3/PresignGetObject.ts index 4534cdac87..39f6c43e2e 100644 --- a/packages/alchemy/src/AWS/S3/PresignGetObject.ts +++ b/packages/alchemy/src/AWS/S3/PresignGetObject.ts @@ -26,7 +26,8 @@ export interface PresignGetObjectRequest { * Presigning is a pure SigV4 computation performed client-side with the * Function's own credentials — no S3 API call is made. Because the URL * inherits the signer's IAM permissions, the binding grants `s3:GetObject` - * on the bucket's objects to the host Function. + * on the bucket's objects to the host Function. See the [S3 presigned URL + * guide](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). * * ### Presigning Download URLs * **Example:** Mint a presigned GET URL @@ -45,6 +46,8 @@ export interface PresignGetObjectRequest { * }); * ``` * + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html + * * @binding */ export interface PresignGetObject extends Binding.Service< diff --git a/packages/alchemy/src/AWS/S3/PresignGetObjectHttp.ts b/packages/alchemy/src/AWS/S3/PresignGetObjectHttp.ts index 92bb57521d..7b0b1a7480 100644 --- a/packages/alchemy/src/AWS/S3/PresignGetObjectHttp.ts +++ b/packages/alchemy/src/AWS/S3/PresignGetObjectHttp.ts @@ -28,7 +28,7 @@ export const PresignGetObjectHttp = Layer.effect( policyStatements: [ { Effect: "Allow", - Action: ["s3:GetObject", "s3:GetObjectVersion"], + Action: ["s3:GetObject"], Resource: [Output.interpolate`${bucket.bucketArn}/*`], }, ], diff --git a/packages/alchemy/src/AWS/SecretsManager/GetSecretValue.ts b/packages/alchemy/src/AWS/SecretsManager/GetSecretValue.ts index eeeb4a605d..890178f002 100644 --- a/packages/alchemy/src/AWS/SecretsManager/GetSecretValue.ts +++ b/packages/alchemy/src/AWS/SecretsManager/GetSecretValue.ts @@ -19,6 +19,12 @@ export interface GetSecretValueRequest extends Omit< * * Secret values are sensitive: `SecretString` / `SecretBinary` may be handed * back wrapped in `Redacted` — unwrap with `Redacted.value` before use. + * + * AWS lists `secretsmanager:GetSecretValue` as the required permission for + * retrieving the value; `DescribeSecret` is metadata-only and is not needed. + * If the secret uses a customer-managed KMS key, the caller additionally + * needs `kms:Decrypt` for that key. See the [GetSecretValue API](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html) + * and [Secrets Manager authorization reference](https://docs.aws.amazon.com/service-authorization/latest/reference/list_secretsmanager.html). * ### Reading Secret Values * **Example:** Read the Current Secret Value * ```typescript @@ -36,6 +42,9 @@ export interface GetSecretValueRequest extends Omit< * : Redacted.value(result.SecretString!); * ``` * + * @see https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html + * @see https://docs.aws.amazon.com/service-authorization/latest/reference/list_secretsmanager.html + * * @binding */ export interface GetSecretValue extends Binding.Service< diff --git a/packages/alchemy/src/AWS/SecretsManager/GetSecretValueHttp.ts b/packages/alchemy/src/AWS/SecretsManager/GetSecretValueHttp.ts index 3b4b2523a9..c4b0ecd492 100644 --- a/packages/alchemy/src/AWS/SecretsManager/GetSecretValueHttp.ts +++ b/packages/alchemy/src/AWS/SecretsManager/GetSecretValueHttp.ts @@ -8,6 +8,6 @@ export const GetSecretValueHttp = Layer.effect( makeSecretHttpBinding({ tag: "AWS.SecretsManager.GetSecretValue", operation: secretsmanager.getSecretValue, - actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"], + actions: ["secretsmanager:GetSecretValue"], }), ); diff --git a/packages/alchemy/test/AWS/S3/Bindings.test.ts b/packages/alchemy/test/AWS/S3/Bindings.test.ts index 34e0619c22..bda39be02b 100644 --- a/packages/alchemy/test/AWS/S3/Bindings.test.ts +++ b/packages/alchemy/test/AWS/S3/Bindings.test.ts @@ -68,6 +68,9 @@ const getJson = (path: string) => return (yield* response.json) as T; }); +const route = (pathname: string, params: Record) => + `${pathname}?${new URLSearchParams(params).toString()}`; + // Probe routes answer `{ tag }` — retry while IAM propagation still yields // AccessDenied so we assert the *expected* typed platform rejection. const getTag = (path: string) => @@ -364,12 +367,38 @@ describe("S3 Bindings", () => { ); }); + describe("HeadObject", () => { + test.provider( + "reads object metadata through the deployed Lambda binding", + (_stack) => + Effect.gen(function* () { + const key = "head/metadata.txt"; + const body = "metadata through the Lambda binding"; + + yield* S3.putObject({ + Bucket: bucketName, + Key: key, + Body: body, + ContentType: "text/plain", + }); + + const result = yield* getJson<{ + contentLength: number; + contentType?: string; + etag?: string; + }>(route("/head-object", { key })); + + expect(result.contentLength).toBe(body.length); + expect(result.contentType).toBe("text/plain"); + expect(result.etag).toBeTruthy(); + }), + { timeout: 120_000 }, + ); + }); + const seed = (key: string, body: string) => S3.putObject({ Bucket: bucketName, Key: key, Body: body }); - const route = (pathname: string, params: Record) => - `${pathname}?${new URLSearchParams(params).toString()}`; - describe("DeleteObjects", () => { test.provider( "batch-deletes several objects in one call", diff --git a/packages/alchemy/test/AWS/S3/fixtures/presign-handler.ts b/packages/alchemy/test/AWS/S3/fixtures/presign-handler.ts index 0e0c26433c..18840c06da 100644 --- a/packages/alchemy/test/AWS/S3/fixtures/presign-handler.ts +++ b/packages/alchemy/test/AWS/S3/fixtures/presign-handler.ts @@ -19,6 +19,7 @@ export default S3PresignTestFunction.make( forceDestroy: true, }); + const headObject = yield* S3.HeadObject(bucket); const presignGetObject = yield* S3.PresignGetObject(bucket); const presignPutObject = yield* S3.PresignPutObject(bucket); const deleteObjects = yield* S3.DeleteObjects(bucket); @@ -67,6 +68,16 @@ export default S3PresignTestFunction.make( return yield* HttpServerResponse.json({ bucketName }); } + if (request.method === "GET" && pathname === "/head-object") { + if (!key) return requireKey(); + const result = yield* headObject({ Key: key }); + return yield* HttpServerResponse.json({ + contentLength: result.ContentLength, + contentType: result.ContentType, + etag: result.ETag, + }); + } + if (request.method === "GET" && pathname === "/presign-get") { if (!key) return requireKey(); const expiresIn = url.searchParams.get("expiresIn"); @@ -289,6 +300,7 @@ export default S3PresignTestFunction.make( }).pipe( Effect.provide( Layer.mergeAll( + S3.HeadObjectHttp, S3.PresignGetObjectHttp, S3.PresignPutObjectHttp, S3.DeleteObjectsHttp,