Skip to content

Commit 5980e27

Browse files
committed
Add opt-in S3 mutation smoke coverage
1 parent 86a452f commit 5980e27

4 files changed

Lines changed: 151 additions & 15 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,14 +343,15 @@ If you need to serve partial files (like for streaming video), `air3` fully supp
343343

344344
If you're using signed URLs and expect clients to send `Range` headers, you must include the exact range claim when generating the URL (e.g., `cmd/signurl -range 'bytes=0-99'`). The Connector forwards authorized ranges to S3, seamlessly returning a `206 Partial Content` response to the client.
345345

346-
## Optional: Read-Only S3-Compatible API
346+
## Optional: S3-Compatible API
347347

348-
While air3 primarily relies on its highly-secure HMAC signed URLs, you can optionally enable a **read-only S3-compatible API** (`AIR3_S3_API_ENABLED=true`). This is extremely useful if your clients or internal tools already use standard S3 SDKs (like `aws-cli` or `boto3`) and you want them to fetch files without modifying their code.
348+
While air3 primarily relies on its highly-secure HMAC signed URLs, you can optionally enable a **path-style S3-compatible API** (`AIR3_S3_API_ENABLED=true`). It is read-only by default and is useful if your clients or internal tools already use standard S3 SDKs (like `aws-cli` or `boto3`) and you want them to fetch files without modifying their code.
349349

350350
### Security First
351351

352352
- **No Real S3 Credentials at the Edge:** You must define a *brand new, gateway-only* set of credentials (`AIR3_S3_API_ACCESS_KEY_ID` and `AIR3_S3_API_SECRET_ACCESS_KEY`). The Edge Gateway only uses these to verify incoming AWS SigV4 requests. It **does not** give the Edge access to the backend storage. These are completely separate from your real, private S3 credentials and your HMAC `AIR3_SIGNING_SECRET`.
353-
- **Mutations Off by Default:** The API defaults to safe read operations: `GetObject`, `HeadObject`, `ListObjectsV2`, and `HeadBucket`. S3-compatible `PUT`/`DELETE` handling must be explicitly gated with `MUTATIONS_ENABLED=true`.
353+
- **Supported v1 Operations:** The API supports read paths `GET`/`GetObject`, `HEAD`/`HeadObject`, `ListObjectsV2`, and `HeadBucket`, plus gated `PutObject` and `DeleteObject`.
354+
- **Mutations Off by Default:** S3-compatible `PUT`/`DELETE` handling must be explicitly gated with `MUTATIONS_ENABLED=true`. `PutObject` requires `Content-Length` and `x-amz-content-sha256: UNSIGNED-PAYLOAD`; multipart upload, ACLs, tagging, versioning, AWS chunked/streaming payloads, and signed-payload `PUT` requests are unsupported.
354355
- **Independent Edge/Connector Gates:** Routed mutations require `MUTATIONS_ENABLED=true` on both the Edge and the target Connector. Direct-server aliases bypass the Connector, so direct alias mutations require only the Edge gate. `AIR3_MUTATIONS_ENABLED` remains as a compatibility alias; if both names are non-empty, their boolean values must match.
355356

356357
### Using the API (Path-Style)

deploy/scripts/smoke-multiserver.sh

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,26 @@ assert_status() {
8080
echo "ok: $label returned HTTP $expected"
8181
}
8282

83-
assert_body() {
83+
assert_body_equals() {
8484
local label=$1
85-
local url=$2
85+
local expected=$2
86+
local url=$3
8687
local body
8788
body=$(curl --silent --show-error --fail --cacert "$CERT_DIR/dev-ca.crt" "$url")
88-
if [ "$body"$'\n' != "$EXPECTED" ] && [ "$body" != "$EXPECTED" ]; then
89+
if [ "$body"$'\n' != "$expected" ] && [ "$body" != "$expected" ]; then
8990
echo "error: $label body did not match expected content" >&2
90-
printf 'expected: %q\nactual: %q\n' "$EXPECTED" "$body" >&2
91+
printf 'expected: %q\nactual: %q\n' "$expected" "$body" >&2
9192
exit 1
9293
fi
9394
echo "ok: $label returned expected content"
9495
}
9596

97+
assert_body() {
98+
local label=$1
99+
local url=$2
100+
assert_body_equals "$label" "$EXPECTED" "$url"
101+
}
102+
96103
assert_head_no_body() {
97104
local label=$1
98105
local url=$2
@@ -162,18 +169,42 @@ aws_s3api() {
162169
local aws_config rc
163170
aws_config=$(mktemp)
164171
temp_files+=("$aws_config")
165-
printf '[default]\ns3 =\n addressing_style = path\n' >"$aws_config"
172+
printf '[default]\ns3 =\n addressing_style = path\n payload_signing_enabled = false\n' >"$aws_config"
166173
AWS_CONFIG_FILE="$aws_config" \
167174
AWS_ACCESS_KEY_ID="$AIR3_S3_API_ACCESS_KEY_ID" \
168175
AWS_SECRET_ACCESS_KEY="$AIR3_S3_API_SECRET_ACCESS_KEY" \
169176
AWS_DEFAULT_REGION="${AIR3_S3_API_REGION:-us-east-1}" \
170177
AWS_PAGER="" \
178+
AWS_REQUEST_CHECKSUM_CALCULATION="when_required" \
179+
AWS_RESPONSE_CHECKSUM_VALIDATION="when_required" \
171180
aws --endpoint-url "$BASE_URL" --ca-bundle "$CERT_DIR/dev-ca.crt" s3api "$@"
172181
rc=$?
173182
rm -f "$aws_config"
174183
return "$rc"
175184
}
176185

186+
mutation_gate_enabled() {
187+
local primary=${MUTATIONS_ENABLED:-}
188+
local alias=${AIR3_MUTATIONS_ENABLED:-}
189+
if [ -n "$primary" ] && [ -n "$alias" ] && [ "$primary" != "$alias" ]; then
190+
return 1
191+
fi
192+
if [ -n "$primary" ]; then
193+
[ "$primary" = "true" ]
194+
return
195+
fi
196+
[ "$alias" = "true" ]
197+
}
198+
199+
mutation_smoke_ready() {
200+
s3_api_smoke_ready || return 1
201+
if ! mutation_gate_enabled; then
202+
echo "skip: optional multi-server S3-compatible mutation smoke checks need MUTATIONS_ENABLED=true (or matching AIR3_MUTATIONS_ENABLED=true)"
203+
return 1
204+
fi
205+
return 0
206+
}
207+
177208
assert_s3_api_body() {
178209
local label=$1
179210
local bucket=$2
@@ -227,6 +258,39 @@ run_optional_s3_api_smoke() {
227258
assert_s3_api_list_contains "direct default-bucket mapping" "$DIRECT_SERVER" "$KEY" "$KEY"
228259
}
229260

261+
run_optional_s3_mutation_smoke() {
262+
mutation_smoke_ready || return 0
263+
264+
echo "Running optional multi-server S3-compatible mutation smoke checks..."
265+
local mutation_key mutation_content mutation_body get_url head_url deleted_url
266+
mutation_key=${AIR3_DEMO_MUTATION_KEY:-"air3-smoke-mutation-$(date +%s)-$$.txt"}
267+
mutation_content=${AIR3_DEMO_MUTATION_CONTENT:-$'air3 routed mutation smoke\n'}
268+
mutation_body=$(mktemp)
269+
temp_files+=("$mutation_body")
270+
printf '%s' "$mutation_content" >"$mutation_body"
271+
272+
if ! aws_s3api put-object --bucket "$BLUE_SERVER" --key "$mutation_key" --body "$mutation_body" --content-type text/plain >/dev/null; then
273+
echo "error: blue routed S3 API PutObject failed" >&2
274+
exit 1
275+
fi
276+
echo "ok: blue routed S3 API PutObject created temporary object"
277+
278+
get_url=$(sign_default_bucket_url GET "$BLUE_SERVER" "$mutation_key" 2m)
279+
assert_body_equals "blue signed GET after routed S3 API PutObject" "$mutation_content" "$get_url"
280+
281+
head_url=$(sign_default_bucket_url HEAD "$BLUE_SERVER" "$mutation_key" 2m)
282+
assert_head_no_body "blue signed HEAD after routed S3 API PutObject" "$head_url"
283+
284+
if ! aws_s3api delete-object --bucket "$BLUE_SERVER" --key "$mutation_key" >/dev/null; then
285+
echo "error: blue routed S3 API DeleteObject failed" >&2
286+
exit 1
287+
fi
288+
echo "ok: blue routed S3 API DeleteObject removed temporary object"
289+
290+
deleted_url=$(sign_default_bucket_url GET "$BLUE_SERVER" "$mutation_key" 2m)
291+
assert_status "blue signed GET after routed S3 API DeleteObject" "404" "$deleted_url"
292+
}
293+
230294
wait_for_blue() {
231295
echo "Waiting for edge gateway at $BASE_URL with server '$BLUE_SERVER'..."
232296
local url
@@ -272,6 +336,7 @@ direct_head_url=$(sign_default_bucket_url HEAD "$DIRECT_SERVER" "$KEY" 2m)
272336
check_optional_head "direct signed" "$direct_head_url"
273337

274338
run_optional_s3_api_smoke
339+
run_optional_s3_mutation_smoke
275340

276341
mutated_url=${blue_get_url/\/$BLUE_SERVER\//\/$GREEN_SERVER\/$BUCKET\/}
277342
if [ "$mutated_url" = "$blue_get_url" ]; then

deploy/scripts/smoke.sh

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,42 @@ s3_api_smoke_ready() {
9393
aws_s3api() {
9494
local aws_config rc
9595
aws_config=$(mktemp)
96-
printf '[default]\ns3 =\n addressing_style = path\n' >"$aws_config"
96+
printf '[default]\ns3 =\n addressing_style = path\n payload_signing_enabled = false\n' >"$aws_config"
9797
AWS_CONFIG_FILE="$aws_config" \
9898
AWS_ACCESS_KEY_ID="$AIR3_S3_API_ACCESS_KEY_ID" \
9999
AWS_SECRET_ACCESS_KEY="$AIR3_S3_API_SECRET_ACCESS_KEY" \
100100
AWS_DEFAULT_REGION="${AIR3_S3_API_REGION:-us-east-1}" \
101101
AWS_PAGER="" \
102+
AWS_REQUEST_CHECKSUM_CALCULATION="when_required" \
103+
AWS_RESPONSE_CHECKSUM_VALIDATION="when_required" \
102104
aws --endpoint-url "$BASE_URL" --ca-bundle "$CERT_DIR/dev-ca.crt" s3api "$@"
103105
rc=$?
104106
rm -f "$aws_config"
105107
return "$rc"
106108
}
107109

110+
mutation_gate_enabled() {
111+
local primary=${MUTATIONS_ENABLED:-}
112+
local alias=${AIR3_MUTATIONS_ENABLED:-}
113+
if [ -n "$primary" ] && [ -n "$alias" ] && [ "$primary" != "$alias" ]; then
114+
return 1
115+
fi
116+
if [ -n "$primary" ]; then
117+
[ "$primary" = "true" ]
118+
return
119+
fi
120+
[ "$alias" = "true" ]
121+
}
122+
123+
mutation_smoke_ready() {
124+
s3_api_smoke_ready || return 1
125+
if ! mutation_gate_enabled; then
126+
echo "skip: optional S3-compatible mutation smoke checks need MUTATIONS_ENABLED=true (or matching AIR3_MUTATIONS_ENABLED=true)"
127+
return 1
128+
fi
129+
return 0
130+
}
131+
108132
run_optional_s3_api_smoke() {
109133
s3_api_smoke_ready || return 0
110134

@@ -148,6 +172,43 @@ run_optional_s3_api_smoke() {
148172
echo "ok: S3 API ListObjectsV2 included $KEY"
149173
}
150174

175+
run_optional_s3_mutation_smoke() {
176+
mutation_smoke_ready || return 0
177+
178+
echo "Running optional S3-compatible mutation smoke checks..."
179+
local mutation_key mutation_content mutation_body get_url deleted_url
180+
mutation_key=${AIR3_DEMO_MUTATION_KEY:-"air3-smoke-mutation-$(date +%s)-$$.txt"}
181+
mutation_content=${AIR3_DEMO_MUTATION_CONTENT:-$'air3 mutation smoke\n'}
182+
mutation_body=$(mktemp)
183+
printf '%s' "$mutation_content" >"$mutation_body"
184+
185+
if ! aws_s3api put-object --bucket "$BUCKET" --key "$mutation_key" --body "$mutation_body" --content-type text/plain >/dev/null; then
186+
rm -f "$mutation_body"
187+
echo "error: S3 API PutObject failed" >&2
188+
exit 1
189+
fi
190+
rm -f "$mutation_body"
191+
echo "ok: S3 API PutObject created temporary object"
192+
193+
get_url=$(sign_url GET "$mutation_key" 2m)
194+
assert_body "signed GET after S3 API PutObject" "$mutation_content" "$get_url"
195+
196+
if ! aws_s3api head-object --bucket "$BUCKET" --key "$mutation_key" >/dev/null; then
197+
echo "error: S3 API HeadObject after PutObject failed" >&2
198+
exit 1
199+
fi
200+
echo "ok: S3 API HeadObject found temporary object"
201+
202+
if ! aws_s3api delete-object --bucket "$BUCKET" --key "$mutation_key" >/dev/null; then
203+
echo "error: S3 API DeleteObject failed" >&2
204+
exit 1
205+
fi
206+
echo "ok: S3 API DeleteObject removed temporary object"
207+
208+
deleted_url=$(sign_url GET "$mutation_key" 2m)
209+
assert_status "signed GET after S3 API DeleteObject" "404" "$deleted_url"
210+
}
211+
151212
wait_for_edge() {
152213
echo "Waiting for edge gateway at $BASE_URL..."
153214
local url
@@ -203,6 +264,7 @@ short_form_url=$(sign_url GET "$SHORT_FORM_COLLISION_KEY" 2m)
203264
assert_body "short-form /$SHORT_FORM_COLLISION_KEY default-bucket routing" "$SHORT_FORM_EXPECTED" "$short_form_url"
204265

205266
run_optional_s3_api_smoke
267+
run_optional_s3_mutation_smoke
206268

207269
bad_url=$(printf '%s' "$get_url" | sed 's/sig=[^&]*/sig=deadbeef/')
208270
assert_status "bad signature rejection" "403" "$bad_url"

docs/configuration.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The Edge Gateway is your public-facing entry point. In the default recommended t
2323
| `S3_{SUFFIX}_BUCKET` | unset | Optional per-server default bucket for multi-server public paths. For alias `blue`, `S3_BLUE_BUCKET=demo` lets signed URLs use `/blue/{key}` instead of `/blue/demo/{key}`. |
2424
| `DIRECT_SERVERS` | unset | Bare fallback name for `AIR3_DIRECT_SERVERS`. If both are set, the values must match exactly or startup fails. |
2525
| `AIR3_EDGE_ALLOWED_CONNECTOR_IDENTITIES` | unset | (Optional) Comma-separated list of allowed Connector certificate identities for mTLS ingest connections. |
26-
| `AIR3_S3_API_ENABLED` | `false` | Opt in to the public read-only S3-compatible API for AWS SigV4-shaped requests. When `false`, normal air3 HMAC signed URLs continue unchanged and S3 API credentials are not required. |
26+
| `AIR3_S3_API_ENABLED` | `false` | Opt in to the public S3-compatible API for AWS SigV4-shaped requests. It is read-only by default; gated mutation operations require `MUTATIONS_ENABLED=true`. When `false`, normal air3 HMAC signed URLs continue unchanged and S3 API credentials are not required. |
2727
| `MUTATIONS_ENABLED` | `false` | Enables S3-compatible mutation methods at the Edge when mutation handling is installed. S3 API mutations are off by default. `AIR3_MUTATIONS_ENABLED` is a compatibility alias; if both names are non-empty, they must parse to the same boolean. Routed Connector mutations require this gate on both Edge and Connector; direct-server alias mutations require only the Edge gate. |
2828
| `AIR3_S3_API_REGION` | `us-east-1` | Region clients must use in the AWS SigV4 credential scope. Region matching is strict. |
2929
| `AIR3_S3_API_ACCESS_KEY_ID` | unset | Public gateway SigV4 verifier access key ID. Required only when `AIR3_S3_API_ENABLED=true`; not a backend connector/direct S3 credential. |
@@ -94,16 +94,17 @@ Use `AIR3_MULTI_SERVER=true` when a single Edge Gateway needs to route requests
9494
**Combining Multi-Server with Short URLs:**
9595
You can assign default buckets to specific server aliases using `S3_{SUFFIX}_BUCKET`. For example, setting `S3_BLUE_BUCKET=demo` allows the Edge to serve `/blue/file.txt` and automatically map it to the `demo` bucket for the "blue" connector.
9696

97-
## 3. Optional Read-Only S3-Compatible API
97+
## 3. Optional S3-Compatible API
9898

99-
The Edge Gateway can expose a **read-only, path-style S3-compatible API** for clients and SDKs (like `aws-cli` or `boto3`) that natively speak AWS SigV4. Enable it with `AIR3_S3_API_ENABLED=true`.
99+
The Edge Gateway can expose a **path-style S3-compatible API** for clients and SDKs (like `aws-cli` or `boto3`) that natively speak AWS SigV4. Enable it with `AIR3_S3_API_ENABLED=true`.
100100

101101
### Security First
102102
You must provide a brand new set of *gateway verifier credentials*: `AIR3_S3_API_ACCESS_KEY_ID` and `AIR3_S3_API_SECRET_ACCESS_KEY`.
103103
- These credentials **only** live on the Edge and are used merely to authenticate incoming AWS SigV4 requests.
104104
- They **do not** grant access to backend S3 storage, and they are completely separate from your real S3 backend credentials.
105-
- By default, the API is read-only (`GetObject`, `HeadObject`, `ListObjectsV2`, `HeadBucket`). S3-compatible mutations (`PUT`/`DELETE`) are disabled unless `MUTATIONS_ENABLED=true` is set.
106-
- Routed S3 mutations require `MUTATIONS_ENABLED=true` on both the Edge and the target Connector. Direct-server aliases bypass the Connector, so direct alias mutations require only the Edge gate.
105+
- Supported v1 operations are read paths `GET`/`GetObject`, `HEAD`/`HeadObject`, `ListObjectsV2`, `HeadBucket`, and gated mutations `PutObject` and `DeleteObject`. S3-compatible mutations (`PUT`/`DELETE`) are disabled unless `MUTATIONS_ENABLED=true` is set.
106+
- `PutObject` accepts only known-length, unsigned-payload uploads: clients must send `Content-Length` and use `x-amz-content-sha256: UNSIGNED-PAYLOAD`. Multipart upload, ACLs, tagging, versioning, AWS chunked transfer (`aws-chunked`), streaming payloads, and signed-payload `PUT` requests are not supported.
107+
- Routed S3 mutations require `MUTATIONS_ENABLED=true` on both the Edge and the target Connector. Direct-server aliases bypass the Connector, so direct alias mutations require only the Edge gate. `AIR3_MUTATIONS_ENABLED` is a compatibility alias; if both names are non-empty, their boolean values must match.
107108

108109
### Path-Style Mapping Examples
109110

@@ -243,13 +244,20 @@ Our included `deploy/compose.yaml` demo clearly illustrates the required network
243244
| `broker` | `edge-gateway`, `nats`, `private-connector` | The middle ground for NATS control messages and Edge ingest routing. |
244245
| `private` | `private-connector`, `versitygw`, `aws-cli` | Deeply isolated S3 access. Marked `internal: true`. The Edge Gateway cannot reach this. |
245246

246-
The Compose demo wires the optional S3-compatible API variables through to the Edge with `AIR3_S3_API_ENABLED=false`, `MUTATIONS_ENABLED=false`, and empty verifier credentials by default, so credentials and mutation support are not required unless you explicitly enable them:
247+
The Compose demo wires the optional S3-compatible API variables through to the Edge with `AIR3_S3_API_ENABLED=false`, `MUTATIONS_ENABLED=false`, and empty verifier credentials by default, so credentials and mutation support are not required unless you explicitly enable them. Leave `MUTATIONS_ENABLED` unset/false for the normal read-only demos; set it only when you intentionally want the opt-in mutation smoke checks to run:
247248

248249
```sh
249250
AIR3_S3_API_ENABLED=true \
250251
AIR3_S3_API_ACCESS_KEY_ID=demo-gateway-access \
251252
AIR3_S3_API_SECRET_ACCESS_KEY=demo-gateway-secret \
252253
make compose-up
254+
255+
# Optional mutation demo/smoke (requires services started with the same gate).
256+
AIR3_S3_API_ENABLED=true \
257+
AIR3_S3_API_ACCESS_KEY_ID=demo-gateway-access \
258+
AIR3_S3_API_SECRET_ACCESS_KEY=demo-gateway-secret \
259+
MUTATIONS_ENABLED=true \
260+
make compose-up
253261
```
254262

255263
## Release artifacts and images

0 commit comments

Comments
 (0)