Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/alchemy/src/AWS/S3/BindingHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) =>
Expand Down
11 changes: 11 additions & 0 deletions packages/alchemy/src/AWS/S3/HeadObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<
Expand Down
3 changes: 2 additions & 1 deletion packages/alchemy/src/AWS/S3/HeadObjectHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);
5 changes: 4 additions & 1 deletion packages/alchemy/src/AWS/S3/PresignGetObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<
Expand Down
2 changes: 1 addition & 1 deletion packages/alchemy/src/AWS/S3/PresignGetObjectHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/*`],
},
],
Expand Down
9 changes: 9 additions & 0 deletions packages/alchemy/src/AWS/SecretsManager/GetSecretValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}),
);
35 changes: 32 additions & 3 deletions packages/alchemy/test/AWS/S3/Bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ const getJson = <T>(path: string) =>
return (yield* response.json) as T;
});

const route = (pathname: string, params: Record<string, string>) =>
`${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) =>
Expand Down Expand Up @@ -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<string, string>) =>
`${pathname}?${new URLSearchParams(params).toString()}`;

describe("DeleteObjects", () => {
test.provider(
"batch-deletes several objects in one call",
Expand Down
12 changes: 12 additions & 0 deletions packages/alchemy/test/AWS/S3/fixtures/presign-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -289,6 +300,7 @@ export default S3PresignTestFunction.make(
}).pipe(
Effect.provide(
Layer.mergeAll(
S3.HeadObjectHttp,
S3.PresignGetObjectHttp,
S3.PresignPutObjectHttp,
S3.DeleteObjectsHttp,
Expand Down