Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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"],
}),
);
64 changes: 64 additions & 0 deletions packages/alchemy/test/AWS/S3/HeadObjectHttp.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests should be in fixtures that actually exercise the binding through a lambda function

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't locally mock. Actually test it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have the pattern established, just add tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the mocks and moved coverage into the existing Lambda fixtures, including HeadObject. Pushed in 46ebfa1; targeted tests pass

Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { HeadObject } from "@/AWS/S3/HeadObject.ts";
import { HeadObjectHttp } from "@/AWS/S3/HeadObjectHttp.ts";
import * as Output from "@/Output.ts";
import { RuntimeContext } from "@/RuntimeContext.ts";
import { Self } from "@/Self.ts";
import { Credentials } from "@distilled.cloud/aws";
import * as Region from "@distilled.cloud/aws/Region";
import { expect, it } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";

it.effect(
"binds version-aware object reads through the production layer",
() => {
let captured: any;
const stored: Record<string, Output.Output> = {};
const runtime = {
Type: "AWS.Lambda.Function",
id: "Host",
env: {},
set: (id: string, output: Output.Output) =>
Effect.sync(() => {
stored[id] = output;
return id;
}),
get: <T>(id: string) =>
Output.evaluate(stored[id], {}) as Effect.Effect<T>,
};
const host = {
Type: "AWS.Lambda.Function",
LogicalId: "Host",
FQN: "Host",
bind: (...args: unknown[]) =>
args[0] instanceof Array
? (binding: unknown) => Effect.sync(() => (captured = binding))
: Effect.void,
};
const bucket = {
Type: "AWS.S3.Bucket",
LogicalId: "Bucket",
FQN: "Bucket",
bucketName: Output.asOutput("bucket"),
bucketArn: Output.asOutput("arn:aws:s3:::bucket"),
} as any;

return Effect.gen(function* () {
const bind = yield* HeadObject;
yield* bind(bucket);
expect(captured.policyStatements[0].Action).toEqual([
"s3:GetObject",
"s3:GetObjectVersion",
]);
expect(captured.policyStatements[1].Action).toEqual(["s3:ListBucket"]);
}).pipe(
Effect.provide(HeadObjectHttp),
Effect.provide(Credentials.mock),
Effect.provide(Region.of("us-east-1")),
Effect.provide(FetchHttpClient.layer),
Effect.provide(Layer.succeed(Self, host)),
Effect.provide(Layer.succeed(RuntimeContext, runtime)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never do subsequent Effect.provide. You need to provide once, use Layer.* to build the layer

);
},
);
54 changes: 54 additions & 0 deletions packages/alchemy/test/AWS/S3/PresignGetObjectHttp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { PresignGetObject } from "@/AWS/S3/PresignGetObject.ts";
import { PresignGetObjectHttp } from "@/AWS/S3/PresignGetObjectHttp.ts";
import * as Output from "@/Output.ts";
import { RuntimeContext } from "@/RuntimeContext.ts";
import { Self } from "@/Self.ts";
import { Credentials } from "@distilled.cloud/aws";
import * as Region from "@distilled.cloud/aws/Region";
import { expect, it } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

it.effect("binds only the unversioned presigned GET permission", () => {
let captured: any;
const stored: Record<string, Output.Output> = {};
const runtime = {
Type: "AWS.Lambda.Function",
id: "Host",
env: {},
set: (id: string, output: Output.Output) =>
Effect.sync(() => {
stored[id] = output;
return id;
}),
get: <T>(id: string) => Output.evaluate(stored[id], {}) as Effect.Effect<T>,
};
const host = {
Type: "AWS.Lambda.Function",
LogicalId: "Host",
FQN: "Host",
bind: (...args: unknown[]) =>
args[0] instanceof Array
? (binding: unknown) => Effect.sync(() => (captured = binding))
: Effect.void,
};
const bucket = {
Type: "AWS.S3.Bucket",
LogicalId: "Bucket",
FQN: "Bucket",
bucketName: Output.asOutput("bucket"),
bucketArn: Output.asOutput("arn:aws:s3:::bucket"),
} as any;

return Effect.gen(function* () {
const bind = yield* PresignGetObject;
yield* bind(bucket);
expect(captured.policyStatements[0].Action).toEqual(["s3:GetObject"]);
}).pipe(
Effect.provide(PresignGetObjectHttp),
Effect.provide(Credentials.mock),
Effect.provide(Region.of("us-east-1")),
Effect.provide(Layer.succeed(Self, host)),
Effect.provide(Layer.succeed(RuntimeContext, runtime)),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { GetSecretValue } from "@/AWS/SecretsManager/GetSecretValue.ts";
import { GetSecretValueHttp } from "@/AWS/SecretsManager/GetSecretValueHttp.ts";
import * as Output from "@/Output.ts";
import { RuntimeContext } from "@/RuntimeContext.ts";
import { Self } from "@/Self.ts";
import { Credentials } from "@distilled.cloud/aws";
import * as Region from "@distilled.cloud/aws/Region";
import { expect, it } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";

it.effect("binds GetSecretValue through the production layer", () => {
let captured: any;
const stored: Record<string, Output.Output> = {};
const runtime = {
Type: "AWS.Lambda.Function",
id: "Host",
env: {},
set: (id: string, output: Output.Output) =>
Effect.sync(() => {
stored[id] = output;
return id;
}),
get: <T>(id: string) => Output.evaluate(stored[id], {}) as Effect.Effect<T>,
};
const host = {
Type: "AWS.Lambda.Function",
LogicalId: "Host",
FQN: "Host",
bind: (...args: unknown[]) =>
args[0] instanceof Array
? (binding: unknown) => Effect.sync(() => (captured = binding))
: Effect.void,
};
const secret = {
Type: "AWS.SecretsManager.Secret",
LogicalId: "Secret",
FQN: "Secret",
secretArn: Output.asOutput("arn:aws:secretsmanager:us-east-1:123:secret:x"),
secretName: Output.asOutput("secret"),
} as any;

return Effect.gen(function* () {
const bind = yield* GetSecretValue;
yield* bind(secret);
expect(captured.policyStatements[0].Action).toEqual([
"secretsmanager:GetSecretValue",
]);
}).pipe(
Effect.provide(GetSecretValueHttp),
Effect.provide(Credentials.mock),
Effect.provide(Region.of("us-east-1")),
Effect.provide(FetchHttpClient.layer),
Effect.provide(Layer.succeed(Self, host)),
Effect.provide(Layer.succeed(RuntimeContext, runtime)),
);
});