patches: add v1.11.0 owner-scoped access patch and its leil-rig proof

0003-owner-scoped-access.patch changes authorization, applied after the
de-brand and dark-theme patches:

- a bucket's ACL and policy may name only the owner's keys (accounts on
  the owner's UserID), whoever writes them, root included;
- public access is read-only: all-users READ, and "*" allowed only
  s3:GetObject and s3:ListBucket;
- the bucket policy never denies the owner's key;
- DeleteBucket, PutBucketVersioning, PutObjectLockConfiguration and
  PutBucketOwnershipControls are reserved to root and admins;
- an ACL with the all-users READ grant can be written back unchanged.

docs/owner-scoped-access.md has the rules, what v1.11.0 already did,
and the leil-rig proof procedure with its 2026-11-10 result: 63 of 63
on the patched build, 29 of 63 failing on stock v1.11.0.
tests/owner-scope/ holds the proof script and a stdlib-only SigV4
request helper. README and the pipeline doc list the patch and how to
re-verify it after a rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
57_WolveandClaude Opus 5.5 committed 2026-11-10 23:50:47 -06:00
1 parent 1984762186
commit 152ea01107
6 files changed
+1963 -6

No files matched your search

+45 -5
View File
@@ -14,6 +14,13 @@ Artifact names are intentionally **unchanged** — the binary stays
drop-in compatible with existing configs, systemd units, and the
`versitygw` CLI. Only the embedded web UI is rebranded to "S3 Gateway".
The v1.11.0 series also carries one behavioural patch,
**owner-scoped bucket access**: a bucket's ACL and policy may name only
the owner's own keys, public access is read-only, and deleting a bucket
or changing its versioning, object lock or ownership controls is left
to the gateway's root and admins. See
[docs/owner-scoped-access.md](docs/owner-scoped-access.md).
```
upstream tag (vX.Y.Z) this repo
github.com/versity/versitygw patches/<tag>/*.patch
@@ -40,15 +47,19 @@ s3-gateway/
├── patches/
│ ├── v1.11.0/ # the supported series
│ │ ├── 0001-webui-debrand.patch
│ │ └── 0002-webui-dark-theme.patch
│ │ ├── 0002-webui-dark-theme.patch
│ │ └── 0003-owner-scoped-access.patch
│ └── archive/ # superseded; kept, not built
│ ├── v1.10.0/
│ ├── v1.8.0/
│ ├── v1.7.0/
│ ├── v1.6.0/
│ └── v1.5.0/
├── tests/owner-scope/ # leil-rig proof test for 0003 (proof.sh, s3req.py)
├── .gitea/workflows/build.yml
└── docs/multi-version-pipeline.md
└── docs/
├── multi-version-pipeline.md
└── owner-scoped-access.md # what 0003 does, and its proof test
```
## Build locally
@@ -86,9 +97,9 @@ publish a build of a superseded release.
Currently maintained:
| Upstream tag | Patch set | Contents |
|--------------|---------------|-----------------------------------------------------------|
| `v1.11.0` | `v1.11.0/` | `0001-webui-debrand.patch`, `0002-webui-dark-theme.patch` |
| Upstream tag | Patch set | Contents |
|--------------|---------------|---------------------------------------------------------------------------------------------|
| `v1.11.0` | `v1.11.0/` | `0001-webui-debrand.patch`, `0002-webui-dark-theme.patch`, `0003-owner-scoped-access.patch` |
**v1.11.0 is the only supported version.** It supersedes `v1.10.0`
with 42 further upstream fixes, so the older series — `v1.10.0`,
@@ -141,6 +152,35 @@ The only markup change is the login page's light background gradient,
which becomes a solid dark surface. It is part of the supported
`v1.11.0` series, and of every archived set except `v1.5.0`.
### What the owner-scoped access patch does
`0003-owner-scoped-access.patch` changes authorization in `auth/` (plus
one call in `s3api/controllers/bucket-put.go`). A bucket's owner is the
account its ACL names; the owner's *keys* are every account with that
account's `UserID`.
- **Only the owner's keys.** `PutBucketPolicy`, `PutBucketAcl` (body
and grant headers) and `CreateBucket`'s grant headers — so also
`versitygw admin create-bucket --grant-*` — refuse any account whose
`UserID` is not the owner's. Root is refused too.
- **Public means read.** `all-users` gets `READ` only; a policy may
allow `"*"` only `s3:GetObject` and `s3:ListBucket`.
`public-read-write` and every other public grant are refused.
- **A policy adds to the ACL.** An `Allow` from either is enough and a
policy `Deny` wins — except that the owner's key is never denied.
- **Root/admin only:** `DeleteBucket`, `PutBucketVersioning`,
`PutObjectLockConfiguration`, `PutBucketOwnershipControls` (and
`DeleteBucketOwnershipControls`).
- An ACL holding the `all-users` `READ` grant can be written back
unchanged (upstream answers `500`).
It is the series' only patch outside `webui/`, and it has unit tests:
after a rebase run `go test ./auth/ ./s3api/...` (on Linux — on Windows
upstream's standalone-IAM, posix and CLI tests fail with or without the
series) and
the leil-rig proof test in
[docs/owner-scoped-access.md](docs/owner-scoped-access.md#proving-the-patch-is-in-on-leil-rig).
## Adding / refreshing a version
When upstream cuts a new release you want a build of, you rebase the
+18 -1
View File
@@ -110,7 +110,24 @@ When upstream releases `vX.Y.Z`:
VGW_REPO_URL="/path/to/local/versitygw" VGW_TAG=vX.Y.Z bash ci/run.sh
```
A clean `git am` plus a populated `src/dist/` means the series is good.
A clean `git am` plus a populated `src/dist/` means the web UI patches
are good. `0003-owner-scoped-access.patch` changes Go code in `auth/`, so
a clean apply is not enough for it: run its unit tests in the patched
tree, on Linux (on Windows several upstream packages fail regardless),
```bash
go test ./auth/ ./s3api/...
```
and its proof test on leil-rig — see
[owner-scoped-access.md](owner-scoped-access.md#proving-the-patch-is-in-on-leil-rig).
If upstream has reworked `auth/access-control.go` or `auth/acl.go`,
re-read that document's rules against the new code rather than only
resolving the conflict.
`src/` may hold someone's local work tree; `ci/clone-source.sh` deletes
it. Point `VGW_SRC_DIR` somewhere fresh (any `*-src/` name is
gitignored) when in doubt.
## Retiring a version
+134
View File
@@ -0,0 +1,134 @@
# Owner-scoped bucket access (`0003-owner-scoped-access.patch`)
`patches/v1.11.0/0003-owner-scoped-access.patch` applies after the de-brand and dark-theme patches. Unlike them it
changes the gateway's behaviour, not its look: who may be named in a bucket's ACL and policy, what the public may be
given, and which bucket settings only the gateway operator may change.
| | |
|---|---|
| Touches | `auth/acl.go`, `auth/access-control.go`, `auth/owner_scope.go` (new), `s3api/controllers/bucket-put.go`, and tests |
| Base | upstream `v1.11.0` (`e42ae7ab`) + `0001` + `0002` |
| Unit tests | `go test ./auth/ ./s3api/...` (table-driven; `owner_scope_test.go` in `auth` and `s3api/controllers`) |
| Proof test | [`tests/owner-scope/proof.sh`](../tests/owner-scope/proof.sh), on leil-rig, below |
## The rules
A bucket belongs to the account its ACL names as owner — the key in the `user.acl` xattr — and to the person behind
that account, who may hold several keys: every key whose IAM account carries the owner account's `UserID`. Sharing a
bucket means handing someone one of those keys, never naming somebody else's.
| Rule | Where | Refused with |
|---|---|---|
| **Only the owner's keys.** Every account named by `PutBucketPolicy`, `PutBucketAcl` (body and `x-amz-grant-*` headers) and `CreateBucket`'s grant headers — so also `versitygw admin create-bucket --grant-*` — must have the owner's `UserID`. The owner is looked up in IAM too: an owner unknown to IAM, or with `UserID` 0, can name no other key. The check is on the document, not the caller: root is refused too. | `auth.UpdateACL`, `auth.VerifyPolicyOwnerScope` | `403 AccessDenied` *"&lt;key&gt; is not an account of the bucket owner &lt;owner&gt;"* |
| **Public means read.** `all-users` may only be granted `READ`; a policy may `Allow` `"*"` only `s3:GetObject` and `s3:ListBucket`. Canned `public-read-write`, any other `all-users` grant and any other public `Allow` — wildcards such as `s3:Get*` included — are refused. A `Deny` for `"*"` grants nothing and is accepted. | same | `403 AccessDenied` *"public access is read-only: …"* |
| **A policy adds to the ACL.** An `Allow` from either is enough; a policy `Deny` wins — except that the bucket owner's key is never denied by the bucket policy. The owner's *other* keys get no exemption. | `auth.verifyResourceAccess` | — |
| **Gateway root/admin only:** `DeleteBucket`, `PutBucketVersioning`, `PutObjectLockConfiguration`, `PutBucketOwnershipControls` (and `DeleteBucketOwnershipControls`, which authorizes as it). Refused to the owner, to grantees, to anything a policy grants and to public requests, before any ACL or policy is read. | `auth.verifyAccessGates` | `403 AccessDenied` *"s3:DeleteBucket is reserved to the gateway's root and admin accounts"* |
| An ACL holding the `all-users` `READ` grant, exactly as `GetBucketAcl` returns it, can be written back with `PutBucketAcl`. Upstream looks `all-users` up as an account and answers `500`. | `auth.UpdateACL` | — |
### What v1.11.0 already did, and what is new
The request was written against a checkout of upstream `main` at `4dc0debf`. That checkout is **older** than
v1.11.0, not newer — `git describe` gives `v1.8.0-78-g4dc0debf` and v1.11.0 is not in its history — and v1.11.0 has
already reworked the code it describes:
- *"`verifyResourceAccess` skips the ACL once a policy exists"* — true at `4dc0debf`, not in v1.11.0. There a policy
statement that does not match falls back to the ACL, and a policy `Deny` wins, so "a policy adds to the ACL" was
already the behaviour. The proof test's control run below confirms it: a key named only in a policy works on stock
v1.11.0.
- *"`verifyBucketPolicy` gives the owner no exemption"* — v1.11.0 exempts the owner only for reading, replacing and
deleting the policy itself (`isBucketPolicyAction`). A `Deny` naming the owner, or `"*"`, still locks the owner
out of everything else. 0003 widens the exemption to every action and removes `isBucketPolicyAction`.
- `PutBucketOwnershipControls`, `PutBucketVersioning` and `PutObjectLockConfiguration` live in
`s3api/controllers/bucket-put.go` as stated; `DeleteBucket` and `DeleteBucketOwnershipControls` are in
`bucket-delete.go`. The gate is in `auth`, so the controllers are unchanged.
### Edges worth knowing
- **ARN principals** (the standalone IAM service, `PrincipalResolver`) skip the per-principal `UserID` check: that
backend fixes every bucket's owner to root and gives every identity root's configured `UserID`, so the rule holds
by construction. The public read-only rule still applies.
- **The check is at write time.** A policy or ACL stored before this build keeps working as stored. The
root/admin-only gate is the exception: it is evaluated per request, so a stored grant of those four actions stops
working at once.
- **An unknown grantee** still gets upstream's error (`accounts does not exist: …`, a `500`); 0003 only adds refusals.
- **Writing `user.acl` directly** (leil-cluster's `leilfs-user` appends same-UID grantees with `setfattr`) bypasses
the gateway and so this check. Today it only ever adds the user's own keys, which the rule allows.
- **Bucket creation** still lets a `userplus` creator set object lock and object ownership on its own new bucket;
only changing them afterwards is reserved.
## Proving the patch is in (on leil-rig)
`--version` reads `1.11.0` with or without the patch, so the proof is behaviour. `tests/owner-scope/proof.sh` starts
a throwaway gateway from a given binary on `127.0.0.1:7390`, with its own internal IAM and posix root under
`/var/tmp/s3gw-owner-scope`, drives every rule through signed S3 requests (`tests/owner-scope/s3req.py`, Python
standard library only, so nothing is installed), and deletes the lot again. Nothing installed is touched — no
package, unit or config — so it does not disturb whichever `versitygw` the rig is pinned to.
It creates four accounts through the admin API, all on leil-cluster's reserved test band:
| Key | UID | Role | Is |
|---|---|---|---|
| `po-owner` | 74990 | userplus | owner of bucket `proof` (`create-bucket --owner po-owner --object-ownership BucketOwnerPreferred --grant-full-control po-full`) |
| `po-full` | 74990 | user | a `FULL_CONTROL` grantee — one of the owner's keys |
| `po-obj` | 74990 | user | in no ACL; named in one policy statement allowing `s3:GetObject`/`PutObject`/`DeleteObject` on `shared/report.txt` |
| `po-foreign` | 74991 | user | somebody else's key |
Take the lease first, and give it back when done (`rig-lease.sh` is in `../leil-rig`):
```bash
RIG_LEASE_WHO="<you>: s3-gateway 0003 proof" ./bin/rig-lease.sh claim --why "s3-gateway 0003 proof on leil-test-1" --minutes 60
```
Build, then copy the binary and the test to the test node (it runs no cluster service, and has `python3`):
```bash
VGW_SRC_DIR=build-src VGW_TAG=v1.11.0 bash ci/run.sh
cd ../leil-rig && source bin/rig-lib.sh
on leil-test-1 'install -d -o leil -g leil /var/tmp/s3gw-0003'
rig_scp ../s3-gateway/build-src/dist/versitygw_linux_amd64_v1/versitygw leil-test-1 /var/tmp/s3gw-0003/versitygw
tar -czf /tmp/owner-scope.tgz -C ../s3-gateway/tests owner-scope
rig_scp /tmp/owner-scope.tgz leil-test-1 /var/tmp/s3gw-0003/owner-scope.tgz
on leil-test-1 'cd /var/tmp/s3gw-0003 && chmod 0755 versitygw && tar -xzf owner-scope.tgz && bash owner-scope/proof.sh $PWD/versitygw'
```
(`VGW_SRC_DIR` keeps `ci/run.sh` away from `src/`, which it would `rm -rf`.) Every line must read `PASS` and the last
`N passed, 0 failed`; the exit status says the same. `KEEP=1` leaves the gateway directory and its log behind,
`PROOF_PORT` moves it off 7390. What it covers, in order:
| Checks | Shows |
|---|---|
| A1–A6 | a foreign key refused as a grantee — by `admin create-bucket --grant-full-control` as root (and no bucket made), by `PutBucketAcl` grant header as the owner and as root, by `PutBucketAcl` body as root — while the owner's own key is granted |
| B1–B2 | a foreign key refused as a policy principal, as the owner and as root |
| C1–C8 | `"*"` `s3:PutObject`, canned `public-read-write` and an `all-users` `WRITE` body refused; a policy allowing `"*"` `s3:GetObject` + `s3:ListBucket` accepted, after which anonymous `GetObject` and `ListBucket` work and anonymous `PutObject` does not |
| D1–D4 | the owner key still writes, reads, deletes and reads the ACL under that public-read-only policy |
| E1–E8 | the owner key still writes, reads, lists and deletes under a policy denying `po-owner` `s3:*` and `"*"` `s3:PutObject`, while the same `Deny` beats `po-full`'s `FULL_CONTROL` grant; the owner then deletes the policy |
| F1–F12 | `po-obj` puts, gets and deletes `shared/report.txt` and nothing else: no other object, no listing, no ACL or policy read or write |
| G0–G11 | with the owner's policy granting `po-full` all four, `po-full` is refused ownership controls (put and delete), versioning, object lock and `DeleteBucket`; so is the owner; `po-full`'s object grant still works, and root changes ownership controls |
| H1–H5 | after canned `public-read`, the ACL `GetBucketAcl` returns is written back unchanged and still grants `all-users` `READ` |
| Z1 | root deletes the bucket |
Release the lease and clean up: `on leil-test-1 'rm -rf /var/tmp/s3gw-0003'`, then `./bin/rig-lease.sh release`.
### Result on leil-rig, 2026-11-10: **PASSED**
Run on `leil-test-1` (AlmaLinux 9.8, kernel 5.14.0-687.53.1, Python 3.9.25) under the lease, from a `ci/run.sh`
build of this series:
| | |
|---|---|
| Build | `VGW_TAG=v1.11.0 bash ci/run.sh` (fresh clone of `v1.11.0`, 0001–0003 by `git am --3way`, GoReleaser); `--version` 1.11.0, `Build` `98b6c69b` |
| `versitygw` (linux amd64) sha256 | `4e06d35b746602ce4c1de4a3f3c307c4648faa2f058625f72305a8848002bfd4` |
| `versitygw_1.11.0_linux_amd64.rpm` sha256 | `e4b0e77ecf003e6ec1d57757d841941ded0bcbd9175e668a6794c0a1c6bf33ba` (built locally; not published, not pinned) |
| `proof.sh` | **63 passed, 0 failed** |
| Unit tests on the node (go1.27.3 linux/amd64) | `go test` of every package except `plugins/`: 35 of 35 ok, among them `auth` and `s3api/...` |
**Control: stock v1.11.0** (0001 + 0002 only, same script, same node): **34 passed, 29 failed.** Root and the owner
named `po-foreign` as grantee and as principal (A1–A5, B1–B2); `"*"` `PutObject` and `public-read-write` were
accepted, and anonymous `PutObject` then succeeded (C1, C2, C8); an `all-users` `WRITE` body and the `GetBucketAcl`
round trip answered `500` (C3, H3); the owner's own `Deny` locked it out of put, get, list and delete (E2–E5); with
the bucket public-read-write, `po-obj` reached objects beyond its one (F4–F7); and the policy grant let `po-full` —
and the owner — change ownership controls, enable object lock, and reach versioning and `DeleteBucket` (G1–G8, which
failed only on "versioning not configured" and "bucket not empty"). Stock v1.11.0 passed F1–F3 and F8–F12 on its own:
the single-object key already worked there; 0003 keeps it working.
Run this test again before pinning any build that carries 0003, in leil-rig or leil-cluster.
File diff suppressed because it is too large. Load diff
+212
View File
@@ -0,0 +1,212 @@
#!/bin/bash
#
# Proof test for patches/v1.11.0/0003-owner-scoped-access.patch.
#
# sudo bash proof.sh /path/to/versitygw
#
# Starts a throwaway gateway from the given binary on 127.0.0.1:${PROOF_PORT:-7390},
# with its own internal IAM and posix root under ${PROOF_DIR:-/var/tmp/s3gw-owner-scope},
# drives every rule in the patch through real S3 requests, and removes the lot
# again. Nothing installed is touched: no package, no unit, no config. Prints one
# line per check and exits non-zero if any check fails. KEEP=1 leaves the gateway
# directory (and its log) behind for a look.
#
# The accounts, all made by root through the admin API:
#
# po-owner uid 74990 userplus owns bucket "proof"
# po-full uid 74990 user FULL_CONTROL grantee (an owner's key)
# po-obj uid 74990 user in no ACL; one policy statement on one object
# po-foreign uid 74991 user somebody else's key
#
# The UIDs are from leil-cluster's reserved test band (74990-74999); nothing
# is chowned, so they only have to be distinct.
set -uo pipefail
VGW=${1:?usage: proof.sh /path/to/versitygw}
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PORT=${PROOF_PORT:-7390}
W=${PROOF_DIR:-/var/tmp/s3gw-owner-scope}
export S3_ENDPOINT=http://127.0.0.1:$PORT S3_REGION=us-east-1
[ -x "$VGW" ] || { echo "FATAL: $VGW is not an executable" >&2; exit 2; }
command -v python3 >/dev/null || { echo "FATAL: python3 is required" >&2; exit 2; }
if (exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then
echo "FATAL: something already listens on 127.0.0.1:$PORT (set PROOF_PORT)" >&2
exit 2
fi
rm -rf "$W" && mkdir -p "$W/iam" "$W/data" "$W/tmp" || exit 2
secret() { python3 -c 'import secrets; print(secrets.token_hex(16))'; }
declare -A KEY SECRET
KEY[root]=proofroot
for who in root po-owner po-full po-obj po-foreign; do
[ "$who" = root ] || KEY[$who]=$who
SECRET[$who]=$(secret)
done
"$VGW" --access "${KEY[root]}" --secret "${SECRET[root]}" --region "$S3_REGION" \
--port "127.0.0.1:$PORT" --iam-dir "$W/iam" posix "$W/data" >"$W/gateway.log" 2>&1 &
GW=$!
cleanup() {
kill "$GW" 2>/dev/null
wait "$GW" 2>/dev/null
[ "${KEEP:-0}" = 1 ] && echo "kept $W" || rm -rf "$W"
}
trap cleanup EXIT
for _ in $(seq 1 50); do
(exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null && break
kill -0 "$GW" 2>/dev/null || { cat "$W/gateway.log"; echo "FATAL: the gateway exited" >&2; exit 2; }
sleep 0.2
done
echo "gateway $("$VGW" --version 2>&1 | head -1) on 127.0.0.1:$PORT, sha256 $(sha256sum "$VGW" | cut -c1-16)"
admin() {
ADMIN_ACCESS_KEY_ID=${KEY[root]} ADMIN_SECRET_ACCESS_KEY=${SECRET[root]} \
"$VGW" admin --region "$S3_REGION" --endpoint-url "$S3_ENDPOINT" "$@"
}
# req <who> <s3req.py args...> -- one request as <who> ("anon" for none); prints the status
req() {
local who=$1; shift
if [ "$who" = anon ]; then
S3_KEY= S3_SECRET= python3 "$HERE/s3req.py" "$@" 2>"$W/tmp/err"
else
S3_KEY=${KEY[$who]} S3_SECRET=${SECRET[$who]} python3 "$HERE/s3req.py" "$@" 2>"$W/tmp/err"
fi
}
PASS=0 FAIL=0
# check <id> <want> <what> <who> <s3req.py args...>
check() {
local id=$1 want=$2 what=$3; shift 3
local got
got=$(req "$@")
if [ "$got" = "$want" ]; then
PASS=$((PASS + 1)); printf 'PASS %-4s %s %s\n' "$id" "$got" "$what"
else
FAIL=$((FAIL + 1)); printf 'FAIL %-4s %s (want %s) %s [%s]\n' "$id" "${got:-none}" "$want" "$what" "$(cat "$W/tmp/err")"
fi
}
# check_cmd <id> <want: ok|refused> <what> <command...>
check_cmd() {
local id=$1 want=$2 what=$3; shift 3
local got=ok
"$@" >"$W/tmp/err" 2>&1 || got=refused
if [ "$got" = "$want" ]; then
PASS=$((PASS + 1)); printf 'PASS %-4s %s %s\n' "$id" "$got" "$what"
else
FAIL=$((FAIL + 1)); printf 'FAIL %-4s %s (want %s) %s [%s]\n' "$id" "$got" "$want" "$what" "$(tr '\n' ' ' <"$W/tmp/err")"
fi
}
doc() { printf '%s' "$2" >"$W/tmp/$1"; echo "$W/tmp/$1"; }
policy() { doc policy.json "{\"Version\":\"2012-10-17\",\"Statement\":[$1]}"; }
acl_body() { # acl_body <grantee> <type> <permission>
doc acl.xml "<AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><Owner><ID>po-owner</ID></Owner><AccessControlList><Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"$2\"><ID>$1</ID></Grantee><Permission>$3</Permission></Grant></AccessControlList></AccessControlPolicy>"
}
BUCKET_RES='["arn:aws:s3:::proof","arn:aws:s3:::proof/*"]'
S3NS='xmlns="http://s3.amazonaws.com/doc/2006-03-01/"'
echo "--- setup (root, admin API)"
check_cmd S1 ok "create-user po-owner" admin create-user --access po-owner --secret "${SECRET[po-owner]}" --role userplus --user-id 74990 --group-id 74990
check_cmd S2 ok "create-user po-full" admin create-user --access po-full --secret "${SECRET[po-full]}" --role user --user-id 74990 --group-id 74990
check_cmd S3 ok "create-user po-obj" admin create-user --access po-obj --secret "${SECRET[po-obj]}" --role user --user-id 74990 --group-id 74990
check_cmd S4 ok "create-user po-foreign" admin create-user --access po-foreign --secret "${SECRET[po-foreign]}" --role user --user-id 74991 --group-id 74991
check_cmd S5 ok "create-bucket proof --owner po-owner --grant-full-control po-full" \
admin create-bucket --owner po-owner --bucket proof --object-ownership BucketOwnerPreferred --grant-full-control po-full
printf 'hello\n' >"$W/tmp/hello.txt"
echo "--- a foreign key is refused as a grantee"
check_cmd A1 refused "admin create-bucket --grant-full-control po-foreign (root)" \
admin create-bucket --owner po-owner --bucket proof-foreign --object-ownership BucketOwnerPreferred --grant-full-control po-foreign
check A2 404 " ...and no bucket proof-foreign was made" root HEAD /proof-foreign
check A3 403 "PutBucketAcl x-amz-grant-read: po-foreign, as the owner" po-owner PUT /proof acl -H "x-amz-grant-read: po-foreign"
check A4 403 "PutBucketAcl x-amz-grant-read: po-foreign, as root" root PUT /proof acl -H "x-amz-grant-read: po-foreign"
check A5 403 "PutBucketAcl body naming po-foreign, as root" root PUT /proof acl --body "$(acl_body po-foreign CanonicalUser FULL_CONTROL)"
check A6 200 "PutBucketAcl x-amz-grant-full-control: po-full (owner's key)" po-owner PUT /proof acl -H "x-amz-grant-full-control: po-full"
echo "--- a foreign key is refused as a principal"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"po-foreign\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::proof/*\"}")
check B1 403 "PutBucketPolicy naming po-foreign, as the owner" po-owner PUT /proof policy --body "$P"
check B2 403 "PutBucketPolicy naming po-foreign, as root" root PUT /proof policy --body "$P"
echo "--- public access is read-only"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"s3:PutObject\",\"Resource\":\"arn:aws:s3:::proof/*\"}")
check C1 403 "PutBucketPolicy allowing \"*\" s3:PutObject" po-owner PUT /proof policy --body "$P"
check C2 403 "PutBucketAcl x-amz-acl: public-read-write" po-owner PUT /proof acl -H "x-amz-acl: public-read-write"
check C3 403 "PutBucketAcl body granting all-users WRITE" po-owner PUT /proof acl --body "$(acl_body all-users Group WRITE)"
check C4 200 "owner PutObject public/hello.txt" po-owner PUT /proof/public/hello.txt --body "$W/tmp/hello.txt"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":[\"s3:GetObject\",\"s3:ListBucket\"],\"Resource\":$BUCKET_RES}")
check C5 204 "PutBucketPolicy allowing \"*\" s3:GetObject + s3:ListBucket" po-owner PUT /proof policy --body "$P"
check C6 200 "anonymous GetObject public/hello.txt" anon GET /proof/public/hello.txt
check C7 200 "anonymous ListBucket" anon GET /proof list-type=2
check C8 403 "anonymous PutObject" anon PUT /proof/public/anon.txt --body "$W/tmp/hello.txt"
echo "--- the owner key keeps its bucket under a public-read-only policy"
check D1 200 "owner PutObject" po-owner PUT /proof/owner/a.txt --body "$W/tmp/hello.txt"
check D2 200 "owner GetObject" po-owner GET /proof/owner/a.txt
check D3 204 "owner DeleteObject" po-owner DELETE /proof/owner/a.txt
check D4 200 "owner GetBucketAcl" po-owner GET /proof acl
echo "--- ...and under a policy that denies it"
P=$(policy "{\"Effect\":\"Deny\",\"Principal\":{\"AWS\":\"po-owner\"},\"Action\":\"s3:*\",\"Resource\":$BUCKET_RES},{\"Effect\":\"Deny\",\"Principal\":\"*\",\"Action\":\"s3:PutObject\",\"Resource\":\"arn:aws:s3:::proof/*\"}")
check E1 204 "PutBucketPolicy denying po-owner s3:* and \"*\" s3:PutObject" po-owner PUT /proof policy --body "$P"
check E2 200 "owner PutObject" po-owner PUT /proof/owner/b.txt --body "$W/tmp/hello.txt"
check E3 200 "owner GetObject" po-owner GET /proof/owner/b.txt
check E4 200 "owner ListBucket" po-owner GET /proof list-type=2
check E5 204 "owner DeleteObject" po-owner DELETE /proof/owner/b.txt
check E6 403 "FULL_CONTROL grantee PutObject: the Deny beats its ACL grant" po-full PUT /proof/full/b.txt --body "$W/tmp/hello.txt"
check E7 200 "FULL_CONTROL grantee GetObject: the ACL still grants it" po-full GET /proof/public/hello.txt
check E8 204 "owner DeleteBucketPolicy" po-owner DELETE /proof policy
echo "--- a key for one object"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"po-obj\"},\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:DeleteObject\"],\"Resource\":\"arn:aws:s3:::proof/shared/report.txt\"}")
check F1 204 "PutBucketPolicy for po-obj on shared/report.txt" po-owner PUT /proof policy --body "$P"
check F2 200 "po-obj PutObject shared/report.txt" po-obj PUT /proof/shared/report.txt --body "$W/tmp/hello.txt"
check F3 200 "po-obj GetObject shared/report.txt" po-obj GET /proof/shared/report.txt
check F4 403 "po-obj GetObject public/hello.txt" po-obj GET /proof/public/hello.txt
check F5 403 "po-obj PutObject shared/other.txt" po-obj PUT /proof/shared/other.txt --body "$W/tmp/hello.txt"
check F6 403 "po-obj DeleteObject public/hello.txt" po-obj DELETE /proof/public/hello.txt
check F7 403 "po-obj ListBucket" po-obj GET /proof list-type=2
check F8 403 "po-obj GetBucketAcl" po-obj GET /proof acl
check F9 403 "po-obj PutBucketAcl" po-obj PUT /proof acl -H "x-amz-grant-read: po-obj"
check F10 403 "po-obj GetBucketPolicy" po-obj GET /proof policy
check F11 403 "po-obj PutBucketPolicy" po-obj PUT /proof policy --body "$P"
check F12 204 "po-obj DeleteObject shared/report.txt" po-obj DELETE /proof/shared/report.txt
echo "--- bucket settings are root's and admins' alone"
OWN=$(doc own.xml "<OwnershipControls $S3NS><Rule><ObjectOwnership>BucketOwnerPreferred</ObjectOwnership></Rule></OwnershipControls>")
VER=$(doc ver.xml "<VersioningConfiguration $S3NS><Status>Enabled</Status></VersioningConfiguration>")
LCK=$(doc lck.xml "<ObjectLockConfiguration $S3NS><ObjectLockEnabled>Enabled</ObjectLockEnabled></ObjectLockConfiguration>")
# A FULL_CONTROL grant alone never reached these four; a policy did, so grant them outright.
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"po-full\"},\"Action\":[\"s3:PutBucketOwnershipControls\",\"s3:PutBucketVersioning\",\"s3:PutBucketObjectLockConfiguration\",\"s3:DeleteBucket\"],\"Resource\":\"arn:aws:s3:::proof\"}")
check G0 204 "PutBucketPolicy granting po-full all four" po-owner PUT /proof policy --body "$P"
check G1 403 "FULL_CONTROL grantee PutBucketOwnershipControls" po-full PUT /proof ownershipControls --body "$OWN"
check G2 403 "FULL_CONTROL grantee DeleteBucketOwnershipControls" po-full DELETE /proof ownershipControls
check G3 403 "FULL_CONTROL grantee PutBucketVersioning" po-full PUT /proof versioning --body "$VER"
check G4 403 "FULL_CONTROL grantee PutObjectLockConfiguration" po-full PUT /proof object-lock --body "$LCK"
check G5 403 "FULL_CONTROL grantee DeleteBucket" po-full DELETE /proof
check G6 403 "owner PutBucketVersioning" po-owner PUT /proof versioning --body "$VER"
check G7 403 "owner PutObjectLockConfiguration" po-owner PUT /proof object-lock --body "$LCK"
check G8 403 "owner DeleteBucket" po-owner DELETE /proof
check G9 204 "owner DeleteBucketPolicy" po-owner DELETE /proof policy
check G10 200 "FULL_CONTROL grantee PutObject (its grant still works)" po-full PUT /proof/full/c.txt --body "$W/tmp/hello.txt"
check G11 200 "root PutBucketOwnershipControls" root PUT /proof ownershipControls --body "$OWN"
echo "--- an ACL with the public read grant writes back unchanged"
check H1 200 "PutBucketAcl x-amz-acl: public-read" po-owner PUT /proof acl -H "x-amz-acl: public-read"
check H2 200 "GetBucketAcl" po-owner GET /proof acl -o "$W/tmp/acl-read.xml"
check H3 200 "PutBucketAcl with that body, unchanged" po-owner PUT /proof acl --body "$W/tmp/acl-read.xml"
check H4 200 "GetBucketAcl again" po-owner GET /proof acl -o "$W/tmp/acl-again.xml"
check_cmd H5 ok " ...and it still grants all-users READ" \
grep -q '<ID>all-users</ID>.*<Permission>READ</Permission>' "$W/tmp/acl-again.xml"
echo "--- cleanup (root)"
for k in public/hello.txt full/c.txt; do req root DELETE "/proof/$k" >/dev/null; done
check Z1 204 "root DeleteBucket" root DELETE /proof
echo
echo "$PASS passed, $FAIL failed"
[ "$FAIL" = 0 ]
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""One S3 request, SigV4-signed with the key in the environment.
S3_ENDPOINT=http://127.0.0.1:7390 S3_KEY=ak S3_SECRET=sk \
s3req.py PUT /bucket/key [query] [--body FILE] [-H 'Name: value'] [-o FILE]
Prints the HTTP status on stdout, and for an error response the S3 error
code and message on stderr. With S3_KEY empty or unset the request is
anonymous. Python standard library only, so it runs on a bare EL9 node.
"""
import base64
import datetime
import hashlib
import hmac
import http.client
import os
import re
import sys
import urllib.parse
UNRESERVED = "-_.~"
def quote(s, safe=""):
return urllib.parse.quote(s, safe=UNRESERVED + safe)
def canonical_query(query):
pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
return "&".join(f"{quote(k)}={quote(v)}" for k, v in sorted(pairs))
def sign(method, host, path, query, headers, body, key, secret, region):
now = datetime.datetime.now(datetime.timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
day = now.strftime("%Y%m%d")
headers["host"] = host
headers["x-amz-date"] = amz_date
headers["x-amz-content-sha256"] = hashlib.sha256(body).hexdigest()
signed = sorted(headers)
canonical = "\n".join([
method,
quote(path, safe="/"),
canonical_query(query),
"".join(f"{h}:{headers[h].strip()}\n" for h in signed),
";".join(signed),
headers["x-amz-content-sha256"],
])
scope = f"{day}/{region}/s3/aws4_request"
to_sign = "\n".join(["AWS4-HMAC-SHA256", amz_date, scope,
hashlib.sha256(canonical.encode()).hexdigest()])
k = ("AWS4" + secret).encode()
for part in (day, region, "s3", "aws4_request"):
k = hmac.new(k, part.encode(), hashlib.sha256).digest()
signature = hmac.new(k, to_sign.encode(), hashlib.sha256).hexdigest()
headers["authorization"] = (f"AWS4-HMAC-SHA256 Credential={key}/{scope}, "
f"SignedHeaders={';'.join(signed)}, Signature={signature}")
def main(argv):
args, headers, body, out = [], {}, b"", None
it = iter(argv)
for a in it:
if a == "--body":
path = next(it)
body = sys.stdin.buffer.read() if path == "-" else open(path, "rb").read()
elif a == "-H":
name, _, value = next(it).partition(":")
headers[name.strip().lower()] = value.strip()
elif a == "-o":
out = next(it)
else:
args.append(a)
if len(args) not in (2, 3):
sys.exit(__doc__)
method, path = args[0].upper(), args[1]
query = args[2] if len(args) == 3 else ""
endpoint = urllib.parse.urlsplit(os.environ.get("S3_ENDPOINT", "http://127.0.0.1:7390"))
region = os.environ.get("S3_REGION", "us-east-1")
key, secret = os.environ.get("S3_KEY", ""), os.environ.get("S3_SECRET", "")
if body:
headers["content-md5"] = base64.b64encode(hashlib.md5(body).digest()).decode()
if key:
sign(method, endpoint.netloc, path, query, headers, body, key, secret, region)
conn = http.client.HTTPConnection(endpoint.hostname, endpoint.port, timeout=30)
target = quote(path, safe="/") + ("?" + query if query else "")
conn.request(method, target, body=body if method in ("PUT", "POST") else None, headers=headers)
resp = conn.getresponse()
data = resp.read()
if out:
with open(out, "wb") as f:
f.write(data)
print(resp.status)
if resp.status >= 300 and data:
code = re.search(rb"<Code>(.*?)</Code>", data)
msg = re.search(rb"<Message>(.*?)</Message>", data)
print((code.group(1) if code else b"").decode(), "-",
(msg.group(1) if msg else data[:200]).decode(errors="replace"), file=sys.stderr)
if __name__ == "__main__":
main(sys.argv[1:])