diff --git a/README.md b/README.md index 0edf41f..b05d911 100644 --- a/README.md +++ b/README.md @@ -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//*.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 diff --git a/docs/multi-version-pipeline.md b/docs/multi-version-pipeline.md index 573f9fa..2840a5d 100644 --- a/docs/multi-version-pipeline.md +++ b/docs/multi-version-pipeline.md @@ -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 diff --git a/docs/owner-scoped-access.md b/docs/owner-scoped-access.md new file mode 100644 index 0000000..2cef678 --- /dev/null +++ b/docs/owner-scoped-access.md @@ -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` *"<key> is not an account of the bucket owner <owner>"* | +| **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=": 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. diff --git a/patches/v1.11.0/0003-owner-scoped-access.patch b/patches/v1.11.0/0003-owner-scoped-access.patch new file mode 100644 index 0000000..dc09de5 --- /dev/null +++ b/patches/v1.11.0/0003-owner-scoped-access.patch @@ -0,0 +1,1446 @@ +From 062bbdbe48709f267f6119457fef65621e3a3212 Mon Sep 17 00:00:00 2001 +From: s3-gateway CI +Date: Tue, 10 Nov 2026 23:30:03 -0600 +Subject: [PATCH 3/3] auth: owner-scoped bucket access + +A bucket belongs to the account named as owner in its ACL (the key in +user.acl) and to the person behind it, who may hold several keys: every +key whose IAM account carries the owner's UserID. Sharing a bucket now +means handing someone one of those keys, never naming somebody else's. + +- Only the owner's keys. PutBucketPolicy, PutBucketAcl (body and grant + headers) and CreateBucket's grant headers -- which is also how the + admin API's create-bucket --grant-* names grantees -- refuse any + named account whose UserID is not the owner's, with AccessDenied. + The owner is looked up in IAM too: an owner unknown to IAM or with + UserID 0 (no POSIX identity, or root's) can name no other key. The + check is on the document, not the caller, so root is refused too. +- Public means read. The all-users group may only be granted READ, and + a policy may allow "*" only s3:GetObject and s3:ListBucket. Canned + public-read-write, any other all-users grant, and a public Allow of + any other action are refused. A Deny for "*" grants nothing and is + accepted. +- A policy adds to the ACL. v1.11.0 already combines them that way: an + Allow from either is enough and a policy Deny wins. New here: the + bucket owner's key is never denied by the bucket policy, so a policy + denying it, denying "*", or granting only public reads cannot lock the + owner out. The owner's other keys get no such exemption. +- Gateway root/admin only. DeleteBucket, PutBucketVersioning, + PutObjectLockConfiguration and PutBucketOwnershipControls (and so + DeleteBucketOwnershipControls, which authorizes as it) are refused to + everyone else -- owner, grantees, policy grants and public requests + alike -- before any ACL or policy is consulted. +- An ACL holding the all-users READ grant, as GetBucketAcl reports it, + can be written back unchanged. It used to be looked up as an account + named "all-users" and fail with a 500. + +Backends that name policy principals by ARN (the standalone IAM +service) 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 there by construction. The public read-only +rule still applies. + +Upstream tests that pinned the old owner-Deny and owner-DeleteBucket +behaviour are updated; owner_scope_test.go in auth and in +s3api/controllers adds table-driven tests for every rule, including the +key that may Get/Put/DeleteObject one object and nothing else. + +Downstream patch for s3-gateway builds. Base: v1.11.0. + +Co-Authored-By: Claude Opus 5.5 +--- + auth/access-control.go | 27 +- + auth/access-control_test.go | 95 +++- + auth/acl.go | 36 +- + auth/owner_scope.go | 189 ++++++++ + auth/owner_scope_test.go | 547 ++++++++++++++++++++++++ + s3api/controllers/bucket-delete_test.go | 9 +- + s3api/controllers/bucket-put.go | 9 + + s3api/controllers/bucket-put_test.go | 12 +- + s3api/controllers/owner_scope_test.go | 222 ++++++++++ + 9 files changed, 1097 insertions(+), 49 deletions(-) + create mode 100644 auth/owner_scope.go + create mode 100644 auth/owner_scope_test.go + create mode 100644 s3api/controllers/owner_scope_test.go + +diff --git a/auth/access-control.go b/auth/access-control.go +index 0150ca2..be6fb8d 100644 +--- a/auth/access-control.go ++++ b/auth/access-control.go +@@ -250,13 +250,15 @@ func authorizeObjectSubset(ctx context.Context, be backend.Backend, opts AccessO + // verifyAccessGates applies the checks that depend on gateway configuration + // rather than on the caller's policies. Readonly mode blocks writes for + // everyone, root and admin included, which is why it runs before any bypass. ++// The gateway-admin actions are refused to everyone else before any ACL or ++// policy could grant them (owner_scope.go). + func verifyAccessGates(opts AccessOptions) error { + if opts.Readonly { + if opts.AclPermission == PermissionWrite || opts.AclPermission == PermissionWriteAcp { + return s3err.GetAPIError(s3err.ErrAccessDenied) + } + } +- return nil ++ return verifyGatewayAdminActions(opts) + } + + // authorizationApplies reports whether policy/ACL evaluation is meaningful +@@ -413,10 +415,13 @@ func principalName(acc Account) string { + // it. The ACL can never deny, and it grants on the whole bucket rather than + // on an object, so its verdict for an action is shared by every key. + // +-// The bucket owner may always read, replace and delete the bucket's +-// policy, whatever the policy says, as an AWS account's root user may on +-// its own bucket. No policy can lock the owner out of repairing it. Root +-// and admin don't need this, because they bypass policy altogether. ++// The bucket owner's key is never denied by the bucket policy: a policy only ++// adds to the ACL, which lets the owner do anything on its own bucket, so ++// neither a Deny naming the owner, nor one for "*", nor a policy granting ++// only public reads can lock the owner out of its bucket or of repairing ++// the policy. Root and admin don't need this, because they bypass policy ++// altogether. The owner's other keys get no such exemption: a policy may ++// narrow them like anyone else. + func verifyResourceAccess(ctx context.Context, be backend.Backend, opts AccessOptions, objects []string, condCtx map[string][]string) ([][]policyDecision, error) { + policy, err := be.GetBucketPolicy(ctx, opts.Bucket) + if err != nil && !errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) { +@@ -451,7 +456,7 @@ func verifyResourceAccess(ctx context.Context, be backend.Backend, opts AccessOp + for j, action := range opts.Actions { + decision := bp.decisionFor(opts.Acc, action, resource, condCtx, be.NormalizeObjectKey) + switch { +- case isOwner && isBucketPolicyAction(action): ++ case isOwner: + decision = policyDecisionAllow + case decision == policyDecisionNoMatch: + decision = aclDecisions[j] +@@ -462,16 +467,6 @@ func verifyResourceAccess(ctx context.Context, be backend.Backend, opts AccessOp + return decisions, nil + } + +-// isBucketPolicyAction reports whether action reads, replaces or deletes +-// the bucket policy itself. +-func isBucketPolicyAction(action Action) bool { +- switch action { +- case GetBucketPolicyAction, PutBucketPolicyAction, DeleteBucketPolicyAction: +- return true +- } +- return false +-} +- + // identityPolicyDecisions evaluates every action in opts.Actions against + // every object key, all in a single request. The result keeps one decision + // per action — Decisions[i][j] for objects[i] and opts.Actions[j], and the +diff --git a/auth/access-control_test.go b/auth/access-control_test.go +index 0b36ec7..2621f7e 100644 +--- a/auth/access-control_test.go ++++ b/auth/access-control_test.go +@@ -1369,9 +1369,9 @@ func TestVerifyAccess_NoActionsFailsClosed(t *testing.T) { + // TestVerifyAccess_BucketPolicyAndACL pins how a bucket's policy and ACL + // combine for a backend with no identity-policy layer: an explicit policy + // Deny wins, and otherwise each action is allowed by either the policy or +-// the ACL. The owner may do anything the policy doesn't deny, and may +-// always manage the policy itself. Any other grantee may do only what its +-// ACL grant maps to. ++// the ACL. The owner's key may do anything but the gateway-admin actions, ++// whatever the policy says. Any other grantee may do only what its ACL grant ++// maps to, plus whatever the policy adds. + func TestVerifyAccess_BucketPolicyAndACL(t *testing.T) { + const bucketArn = "arn:aws:s3:::bucket" + grant := func(access string, permission Permission) Grantee { +@@ -1391,7 +1391,14 @@ func TestVerifyAccess_BucketPolicyAndACL(t *testing.T) { + unrelatedPolicy := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":["someone"]},"Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket/*"}]}` + denyReaderListPolicy := `{"Statement":[{"Effect":"Deny","Principal":{"AWS":["reader"]},"Action":"s3:ListBucket","Resource":"arn:aws:s3:::bucket"}]}` + allowWriterPolicyPolicy := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":["writer"]},"Action":"s3:PutBucketPolicy","Resource":"arn:aws:s3:::bucket"}]}` ++ publicReadPolicy := `{"Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::bucket","arn:aws:s3:::bucket/*"]}]}` ++ denyOwnerPolicy := `{"Statement":[{"Effect":"Deny","Principal":{"AWS":["owner"]},"Action":"s3:*","Resource":["arn:aws:s3:::bucket","arn:aws:s3:::bucket/*"]}]}` ++ allowReaderPutPolicy := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":["reader"]},"Action":"s3:PutObject","Resource":"arn:aws:s3:::bucket/*"}]}` ++ denyFullPutPolicy := `{"Statement":[{"Effect":"Deny","Principal":{"AWS":["full"]},"Action":"s3:PutObject","Resource":"arn:aws:s3:::bucket/*"}]}` + accessDenied := s3err.GetAPIError(s3err.ErrAccessDenied) ++ adminOnly := func(action Action) error { ++ return accessDeniedErr(string(action) + " is reserved to the gateway's root and admin accounts") ++ } + + tests := []struct { + name string +@@ -1411,14 +1418,58 @@ func TestVerifyAccess_BucketPolicyAndACL(t *testing.T) { + policy: denyProbePolicy, access: "owner", action: GetBucketTaggingAction, + }, + { +- name: "owner is bound by an explicit deny", ++ name: "owner is never denied by an explicit deny", + policy: denyProbePolicy, access: "owner", object: "deny-probe/key", action: GetObjectAction, +- want: s3err.GetExplicitDenyAccessErr("owner", string(GetObjectAction), "arn:aws:s3:::bucket/deny-probe/key", "a resource-based policy"), + }, + { +- name: "owner is bound by a deny of every action", ++ name: "owner is never denied by a deny of every action", + policy: denyAllPolicy, access: "owner", action: ListBucketAction, +- want: s3err.GetExplicitDenyAccessErr("owner", string(ListBucketAction), bucketArn, "a resource-based policy"), ++ }, ++ { ++ name: "owner writes past a deny of every action", ++ policy: denyAllPolicy, access: "owner", object: "key", action: PutObjectAction, ++ }, ++ { ++ name: "owner writes past a deny naming the owner", ++ policy: denyOwnerPolicy, access: "owner", object: "key", action: PutObjectAction, ++ }, ++ { ++ name: "owner rewrites the ACL past a deny naming the owner", ++ policy: denyOwnerPolicy, access: "owner", action: PutBucketAclAction, ++ }, ++ { ++ name: "owner keeps writing under a public-read-only policy", ++ policy: publicReadPolicy, access: "owner", object: "key", action: PutObjectAction, ++ }, ++ { ++ name: "owner keeps deleting under a public-read-only policy", ++ policy: publicReadPolicy, access: "owner", object: "key", action: DeleteObjectAction, ++ }, ++ { ++ name: "owner keeps the ACL under a public-read-only policy", ++ policy: publicReadPolicy, access: "owner", action: PutBucketAclAction, ++ }, ++ { ++ name: "public-read-only policy lets any key read", ++ policy: publicReadPolicy, access: "stranger", object: "key", action: GetObjectAction, ++ }, ++ { ++ name: "public-read-only policy lets any key list", ++ policy: publicReadPolicy, access: "stranger", action: ListBucketAction, ++ }, ++ { ++ name: "public-read-only policy lets no stranger write", ++ policy: publicReadPolicy, access: "stranger", object: "key", action: PutObjectAction, ++ want: accessDenied, ++ }, ++ { ++ name: "public-read-only policy keeps a WRITE grant", ++ policy: publicReadPolicy, access: "writer", object: "key", action: PutObjectAction, ++ }, ++ { ++ name: "a deny of every action still binds the owner's grantees", ++ policy: denyAllPolicy, access: "full", object: "key", action: GetObjectAction, ++ want: s3err.GetExplicitDenyAccessErr("full", string(GetObjectAction), "arn:aws:s3:::bucket/key", "a resource-based policy"), + }, + { + name: "owner reads the policy past a deny of every action", +@@ -1433,13 +1484,22 @@ func TestVerifyAccess_BucketPolicyAndACL(t *testing.T) { + policy: denyAllPolicy, access: "owner", action: DeleteBucketPolicyAction, + }, + { +- name: "owner's policy status read is bound by a deny of every action", ++ name: "owner reads the policy status past a deny of every action", + policy: denyAllPolicy, access: "owner", action: GetBucketPolicyStatusAction, +- want: s3err.GetExplicitDenyAccessErr("owner", string(GetBucketPolicyStatusAction), bucketArn, "a resource-based policy"), + }, + { + name: "owner without any ACL grant", +- access: "owner", action: PutBucketVersioningAction, disableACL: true, ++ access: "owner", action: PutBucketTaggingAction, disableACL: true, ++ }, ++ { ++ name: "owner is refused versioning", ++ access: "owner", action: PutBucketVersioningAction, ++ want: adminOnly(PutBucketVersioningAction), ++ }, ++ { ++ name: "owner is refused bucket deletion", ++ access: "owner", action: DeleteBucketAction, ++ want: adminOnly(DeleteBucketAction), + }, + { + name: "ACL grant counts when the policy doesn't name the grantee", +@@ -1454,6 +1514,19 @@ func TestVerifyAccess_BucketPolicyAndACL(t *testing.T) { + policy: denyReaderListPolicy, access: "reader", action: ListBucketAction, + want: s3err.GetExplicitDenyAccessErr("reader", string(ListBucketAction), bucketArn, "a resource-based policy"), + }, ++ { ++ name: "explicit deny overrides a FULL_CONTROL grant", ++ policy: denyFullPutPolicy, access: "full", object: "key", action: PutObjectAction, ++ want: s3err.GetExplicitDenyAccessErr("full", string(PutObjectAction), "arn:aws:s3:::bucket/key", "a resource-based policy"), ++ }, ++ { ++ name: "policy adds a write to a READ grant", ++ policy: allowReaderPutPolicy, access: "reader", object: "key", action: PutObjectAction, ++ }, ++ { ++ name: "READ grant still lists under a policy adding a write", ++ policy: allowReaderPutPolicy, access: "reader", action: ListBucketAction, ++ }, + { + name: "READ grants no s3:GetObjectAttributes", + access: "reader", object: "key", action: GetObjectAttributesAction, +@@ -1495,7 +1568,7 @@ func TestVerifyAccess_BucketPolicyAndACL(t *testing.T) { + { + name: "WRITE grants no bucket deletion", + access: "writer", action: DeleteBucketAction, +- want: accessDenied, ++ want: adminOnly(DeleteBucketAction), + }, + { + name: "policy grants s3:PutBucketPolicy to a non-owner", +diff --git a/auth/acl.go b/auth/acl.go +index 05c9101..ca6327b 100644 +--- a/auth/acl.go ++++ b/auth/acl.go +@@ -269,18 +269,8 @@ func UpdateACL(input *PutBucketAclInput, acl ACL, iam IAMService) ([]byte, error + Type: types.TypeGroup, + }) + case types.BucketCannedACLPublicReadWrite: +- defaultGrantees = append(defaultGrantees, []Grantee{ +- { +- Permission: PermissionRead, +- Access: "all-users", +- Type: types.TypeGroup, +- }, +- { +- Permission: PermissionWrite, +- Access: "all-users", +- Type: types.TypeGroup, +- }, +- }...) ++ // Public access is read-only (owner_scope.go). ++ return nil, verifyPublicACLGrant(PermissionWrite) + } + } else { + accs := []string{} +@@ -347,6 +337,22 @@ func UpdateACL(input *PutBucketAclInput, acl ACL, iam IAMService) ([]byte, error + return nil, s3err.GetAPIError(s3err.ErrInvalidRequest) + } + ++ // The public grant GetBucketAcl reports is kept as the ++ // group grant it is, so an ACL can be written back as it ++ // was read, rather than looked up as an account named ++ // "all-users". ++ if isPublicGrant(grt) { ++ if err := verifyPublicACLGrant(grt.Permission); err != nil { ++ return nil, err ++ } ++ defaultGrantees = append(defaultGrantees, Grantee{ ++ Access: "all-users", ++ Permission: grt.Permission, ++ Type: types.TypeGroup, ++ }) ++ continue ++ } ++ + access := grt.Grantee.ID + defaultGrantees = append(defaultGrantees, Grantee{ + Access: access, +@@ -368,6 +374,12 @@ func UpdateACL(input *PutBucketAclInput, acl ACL, iam IAMService) ([]byte, error + if len(accList) > 0 { + return nil, fmt.Errorf("accounts does not exist: %s", strings.Join(accList, ", ")) + } ++ ++ // Only the bucket owner's own keys may be granted anything ++ // (owner_scope.go). ++ if err := verifyOwnerAccounts(iam, acl.Owner, accs); err != nil { ++ return nil, err ++ } + } + + acl.Grantees = defaultGrantees +diff --git a/auth/owner_scope.go b/auth/owner_scope.go +new file mode 100644 +index 0000000..2ae95f2 +--- /dev/null ++++ b/auth/owner_scope.go +@@ -0,0 +1,189 @@ ++// Copyright 2026 s3-gateway contributors ++// This file is licensed under the Apache License, Version 2.0 ++// (the "License"); you may not use this file except in compliance ++// with the License. You may obtain a copy of the License at ++// ++// http://www.apache.org/licenses/LICENSE-2.0 ++// ++// Unless required by applicable law or agreed to in writing, ++// software distributed under the License is distributed on an ++// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY ++// KIND, either express or implied. See the License for the ++// specific language governing permissions and limitations ++// under the License. ++ ++package auth ++ ++import ( ++ "encoding/json" ++ "errors" ++ "fmt" ++ "maps" ++ "net/http" ++ "slices" ++ "strings" ++ ++ "github.com/aws/aws-sdk-go-v2/service/s3/types" ++ "github.com/versity/versitygw/s3err" ++) ++ ++// Owner-scoped access. ++// ++// A bucket belongs to the account named as its owner in the bucket ACL, and ++// to the person behind that account, who may hold several access keys: every ++// key whose IAM account carries the owner account's UserID. Sharing a bucket ++// therefore means handing one of those keys to someone, never naming ++// somebody else's key: ++// ++// - The bucket ACL and the bucket policy may name only the owner's keys, ++// whoever writes them, root included. That covers PutBucketAcl (body and ++// grant headers), PutBucketPolicy, and the grant headers of ++// CreateBucket, which is also how the admin API's create-bucket names ++// grantees. ++// - Everyone else gets read access or nothing: the all-users group may ++// only be granted READ, and a policy may allow "*" only s3:GetObject and ++// s3:ListBucket. ++// - The settings that change what the bucket is, rather than who may use ++// it, are the gateway operator's alone: see gatewayAdminActions. ++ ++// publicPolicyActions are the only actions a bucket policy may allow every ++// principal ("*"): reading an object and listing the bucket. ++var publicPolicyActions = []Action{GetObjectAction, ListBucketAction} ++ ++// gatewayAdminActions are reserved to the gateway's root and admin accounts. ++// Neither the bucket owner nor any grant or policy can authorize them: they ++// delete the bucket, or change its versioning, object lock or ownership ++// controls, none of which a bucket owner can undo once done. ++// DeleteBucketOwnershipControls authorizes as s3:PutBucketOwnershipControls, ++// so it is covered too. ++var gatewayAdminActions = []Action{ ++ DeleteBucketAction, ++ PutBucketVersioningAction, ++ PutBucketObjectLockConfigurationAction, ++ PutBucketOwnershipControlsAction, ++} ++ ++// verifyGatewayAdminActions refuses any action in gatewayAdminActions to a ++// caller that is neither root nor an admin, before any ACL or policy is ++// consulted. ++func verifyGatewayAdminActions(opts AccessOptions) error { ++ if opts.IsRoot || opts.Acc.Role == RoleAdmin { ++ return nil ++ } ++ for _, action := range opts.Actions { ++ if slices.Contains(gatewayAdminActions, action) { ++ return accessDeniedErr(fmt.Sprintf("%s is reserved to the gateway's root and admin accounts", action)) ++ } ++ } ++ return nil ++} ++ ++// accessDeniedErr is an AccessDenied that says why, for refusals a caller ++// can only fix by asking for something else. ++func accessDeniedErr(description string) error { ++ return s3err.APIError{ ++ Code: "AccessDenied", ++ Description: description, ++ HTTPStatusCode: http.StatusForbidden, ++ } ++} ++ ++// verifyOwnerAccounts checks that every access key in accesses belongs to the ++// bucket owner: it is the owner's key itself, or its IAM account carries the ++// owner account's UserID. The owner is looked up in IAM too, so a bucket ++// whose owner key is unknown to IAM, or whose owner has UserID 0 (no POSIX ++// identity, or root's), can name no other key at all. ++func verifyOwnerAccounts(iam IAMService, owner string, accesses []string) error { ++ var ownerAcc *Account ++ for _, access := range accesses { ++ if access == owner { ++ continue ++ } ++ if ownerAcc == nil { ++ acc, err := iam.GetUserAccount(owner) ++ if err != nil && !errors.Is(err, ErrNoSuchUser) { ++ return fmt.Errorf("look up bucket owner %q: %w", owner, err) ++ } ++ if err != nil || acc.UserID == 0 { ++ return accessDeniedErr(fmt.Sprintf("the bucket owner %s has no UserID to share, so %s cannot be named", owner, access)) ++ } ++ ownerAcc = &acc ++ } ++ ++ acc, err := iam.GetUserAccount(access) ++ if err != nil && !errors.Is(err, ErrNoSuchUser) { ++ return fmt.Errorf("look up account %q: %w", access, err) ++ } ++ if err != nil || acc.UserID != ownerAcc.UserID { ++ return accessDeniedErr(fmt.Sprintf("%s is not an account of the bucket owner %s", access, owner)) ++ } ++ } ++ return nil ++} ++ ++// verifyPublicACLGrant accepts the one grant the all-users group may hold: ++// READ. ++func verifyPublicACLGrant(permission Permission) error { ++ if permission != PermissionRead { ++ return accessDeniedErr(fmt.Sprintf("public access is read-only: all-users cannot be granted %s", permission)) ++ } ++ return nil ++} ++ ++// isPublicGrant reports whether g names the all-users group, the form ++// GetBucketAcl reports a public grant in. ++func isPublicGrant(g Grant) bool { ++ return g.Grantee != nil && g.Grantee.Type == types.TypeGroup && g.Grantee.ID == "all-users" ++} ++ ++// VerifyPolicyOwnerScope checks a bucket policy that ValidatePolicyDocument ++// has already accepted against owner-scoped access: an Allow for "*" may ++// grant only publicPolicyActions, and every principal any statement names ++// must be one of the bucket owner's keys. A Deny for "*" grants nothing and ++// may name any action. ++// ++// Where the IAM backend names principals by ARN (PrincipalResolver, the ++// standalone IAM service) the key check does not apply: that backend fixes ++// every bucket's owner to root and gives every identity the same configured ++// UserID as root, so each principal it accepts already has the owner's. ++func VerifyPolicyOwnerScope(policyBin []byte, owner string, iam IAMService) error { ++ var policy BucketPolicy ++ if err := json.Unmarshal(policyBin, &policy); err != nil { ++ return getMalformedPolicyError(policyErrInvalidPolicy) ++ } ++ ++ _, byArn := iam.(PrincipalResolver) ++ for _, statement := range policy.Statement { ++ if statement.Principals.isPublic() { ++ if statement.Effect != BucketPolicyAccessTypeAllow { ++ continue ++ } ++ actions := slices.Sorted(maps.Keys(statement.Actions)) ++ for _, action := range actions { ++ if !slices.Contains(publicPolicyActions, action) { ++ return accessDeniedErr(fmt.Sprintf("public access is read-only: a policy may allow \"*\" only %s, not %s", ++ joinActions(publicPolicyActions), action)) ++ } ++ } ++ continue ++ } ++ if byArn { ++ continue ++ } ++ ++ principals := statement.Principals.ToSlice() ++ slices.Sort(principals) ++ if err := verifyOwnerAccounts(iam, owner, principals); err != nil { ++ return err ++ } ++ } ++ return nil ++} ++ ++func joinActions(actions []Action) string { ++ s := make([]string, len(actions)) ++ for i, a := range actions { ++ s[i] = string(a) ++ } ++ return strings.Join(s, " and ") ++} +diff --git a/auth/owner_scope_test.go b/auth/owner_scope_test.go +new file mode 100644 +index 0000000..a8188cb +--- /dev/null ++++ b/auth/owner_scope_test.go +@@ -0,0 +1,547 @@ ++// Copyright 2026 s3-gateway contributors ++// This file is licensed under the Apache License, Version 2.0 ++// (the "License"); you may not use this file except in compliance ++// with the License. You may obtain a copy of the License at ++// ++// http://www.apache.org/licenses/LICENSE-2.0 ++// ++// Unless required by applicable law or agreed to in writing, ++// software distributed under the License is distributed on an ++// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY ++// KIND, either express or implied. See the License for the ++// specific language governing permissions and limitations ++// under the License. ++ ++package auth ++ ++import ( ++ "encoding/json" ++ "encoding/xml" ++ "errors" ++ "testing" ++ ++ "github.com/aws/aws-sdk-go-v2/service/s3/types" ++ "github.com/stretchr/testify/assert" ++ "github.com/versity/versitygw/backend" ++ "github.com/versity/versitygw/s3err" ++) ++ ++// ownerScopeIAM is an IAM backend holding a fixed set of accounts: the ++// bucket owner "owner" and two more of its keys on UserID 70001, a foreign ++// key on another UserID, two keys with no UserID, and the gateway root. ++type ownerScopeIAM struct { ++ IAMService ++ accounts map[string]Account ++} ++ ++func newOwnerScopeIAM() *ownerScopeIAM { ++ return &ownerScopeIAM{accounts: map[string]Account{ ++ "owner": {Access: "owner", Role: RoleUserPlus, UserID: 70001, GroupID: 70001}, ++ "owner-ci": {Access: "owner-ci", Role: RoleUser, UserID: 70001, GroupID: 70001}, ++ "owner-ro": {Access: "owner-ro", Role: RoleUser, UserID: 70001, GroupID: 70001}, ++ "foreign": {Access: "foreign", Role: RoleUser, UserID: 70002, GroupID: 70002}, ++ "nouid": {Access: "nouid", Role: RoleUser}, ++ "nouid-2": {Access: "nouid-2", Role: RoleUser}, ++ "root": {Access: "root", Role: RoleAdmin}, ++ }} ++} ++ ++func (m *ownerScopeIAM) GetUserAccount(access string) (Account, error) { ++ acc, ok := m.accounts[access] ++ if !ok { ++ return Account{}, ErrNoSuchUser ++ } ++ return acc, nil ++} ++ ++func (m *ownerScopeIAM) ResolveAccounts(accessKeyIDs []string) ([]string, error) { ++ return resolveAccountsByLookup(accessKeyIDs, m.GetUserAccount) ++} ++ ++// arnOwnerScopeIAM names principals by ARN, as the standalone IAM service ++// does, and accepts every principal it is asked about. ++type arnOwnerScopeIAM struct { ++ ownerScopeIAM ++} ++ ++func (arnOwnerScopeIAM) ResolvePrincipals([]string) ([]string, error) { return nil, nil } ++ ++func notOwnerAccount(access, owner string) error { ++ return accessDeniedErr(access + " is not an account of the bucket owner " + owner) ++} ++ ++func noOwnerUserID(owner, access string) error { ++ return accessDeniedErr("the bucket owner " + owner + " has no UserID to share, so " + access + " cannot be named") ++} ++ ++func publicReadOnlyACL(permission Permission) error { ++ return accessDeniedErr("public access is read-only: all-users cannot be granted " + string(permission)) ++} ++ ++func publicReadOnlyPolicy(action Action) error { ++ return accessDeniedErr(`public access is read-only: a policy may allow "*" only s3:GetObject and s3:ListBucket, not ` + string(action)) ++} ++ ++func grantBody(grants ...Grant) *AccessControlPolicy { ++ owner := "owner" ++ return &AccessControlPolicy{ ++ Owner: &types.Owner{ID: &owner}, ++ AccessControlList: AccessControlList{Grants: grants}, ++ } ++} ++ ++func userGrant(id string, permission Permission) Grant { ++ return Grant{Grantee: &Grt{ID: id, Type: types.TypeCanonicalUser}, Permission: permission} ++} ++ ++func allUsersGrant(permission Permission) Grant { ++ return Grant{Grantee: &Grt{ID: "all-users", Type: types.TypeGroup}, Permission: permission} ++} ++ ++// TestUpdateACL_OwnerScope pins what an ACL may name, through every input ++// UpdateACL takes: the grant headers (PutBucketAcl, CreateBucket and so the ++// admin API's create-bucket), the request body, and the canned ACLs. Who ++// asks does not enter into it: UpdateACL is never told the caller, so root ++// is refused exactly as anyone else. ++func TestUpdateACL_OwnerScope(t *testing.T) { ++ owner := Grantee{Access: "owner", Permission: PermissionFullControl, Type: types.TypeCanonicalUser} ++ user := func(access string, permission Permission) Grantee { ++ return Grantee{Access: access, Permission: permission, Type: types.TypeCanonicalUser} ++ } ++ public := Grantee{Access: "all-users", Permission: PermissionRead, Type: types.TypeGroup} ++ str := func(s string) *string { return &s } ++ ++ tests := []struct { ++ name string ++ owner string ++ input PutBucketAclInput ++ want []Grantee ++ err error ++ }{ ++ { ++ name: "grant headers name the owner's other keys", ++ input: PutBucketAclInput{GrantFullControl: str("owner-ci"), GrantRead: str("owner-ro")}, ++ want: []Grantee{owner, user("owner-ci", PermissionFullControl), user("owner-ro", PermissionRead)}, ++ }, ++ { ++ name: "grant header names the owner itself", ++ input: PutBucketAclInput{GrantFullControl: str("owner")}, ++ want: []Grantee{owner, owner}, ++ }, ++ { ++ name: "grant header names a foreign key", ++ input: PutBucketAclInput{GrantFullControl: str("foreign")}, ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "write grant header names a foreign key", ++ input: PutBucketAclInput{GrantWrite: str("foreign")}, ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "a foreign key among the owner's", ++ input: PutBucketAclInput{GrantRead: str("owner-ro,foreign")}, ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "empty grant headers, as CreateBucket passes them", ++ input: PutBucketAclInput{GrantFullControl: str(""), GrantRead: str(""), GrantReadACP: str(""), GrantWrite: str(""), GrantWriteACP: str("")}, ++ want: []Grantee{owner}, ++ }, ++ { ++ name: "body names the owner's other key", ++ input: PutBucketAclInput{AccessControlPolicy: grantBody(userGrant("owner-ci", PermissionWrite))}, ++ want: []Grantee{owner, user("owner-ci", PermissionWrite)}, ++ }, ++ { ++ name: "body names a foreign key", ++ input: PutBucketAclInput{AccessControlPolicy: grantBody(userGrant("owner-ci", PermissionRead), userGrant("foreign", PermissionRead))}, ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "body keeps the public read grant", ++ input: PutBucketAclInput{AccessControlPolicy: grantBody(userGrant("owner", PermissionFullControl), allUsersGrant(PermissionRead))}, ++ want: []Grantee{owner, owner, public}, ++ }, ++ { ++ name: "body grants all-users WRITE", ++ input: PutBucketAclInput{AccessControlPolicy: grantBody(allUsersGrant(PermissionWrite))}, ++ err: publicReadOnlyACL(PermissionWrite), ++ }, ++ { ++ name: "body grants all-users FULL_CONTROL", ++ input: PutBucketAclInput{AccessControlPolicy: grantBody(allUsersGrant(PermissionFullControl))}, ++ err: publicReadOnlyACL(PermissionFullControl), ++ }, ++ { ++ name: "body grants all-users WRITE_ACP", ++ input: PutBucketAclInput{AccessControlPolicy: grantBody(allUsersGrant(PermissionWriteAcp))}, ++ err: publicReadOnlyACL(PermissionWriteAcp), ++ }, ++ { ++ name: "canned private", ++ input: PutBucketAclInput{ACL: types.BucketCannedACLPrivate}, ++ want: []Grantee{owner}, ++ }, ++ { ++ name: "canned public-read", ++ input: PutBucketAclInput{ACL: types.BucketCannedACLPublicRead}, ++ want: []Grantee{owner, public}, ++ }, ++ { ++ name: "canned public-read-write", ++ input: PutBucketAclInput{ACL: types.BucketCannedACLPublicReadWrite}, ++ err: publicReadOnlyACL(PermissionWrite), ++ }, ++ { ++ name: "an owner with no UserID names nobody else", ++ owner: "nouid", ++ input: PutBucketAclInput{GrantRead: str("nouid-2")}, ++ err: noOwnerUserID("nouid", "nouid-2"), ++ }, ++ { ++ name: "a root-owned bucket names nobody else", ++ owner: "root", ++ input: PutBucketAclInput{GrantFullControl: str("owner-ci")}, ++ err: noOwnerUserID("root", "owner-ci"), ++ }, ++ { ++ name: "an owner unknown to IAM names nobody else", ++ owner: "ghost", ++ input: PutBucketAclInput{GrantFullControl: str("owner-ci")}, ++ err: noOwnerUserID("ghost", "owner-ci"), ++ }, ++ { ++ name: "an unknown grantee keeps upstream's error", ++ input: PutBucketAclInput{GrantRead: str("nobody")}, ++ err: errors.New("accounts does not exist: nobody"), ++ }, ++ } ++ ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ owner := tt.owner ++ if owner == "" { ++ owner = "owner" ++ } ++ if tt.input.AccessControlPolicy != nil { ++ tt.input.AccessControlPolicy.Owner.ID = &owner ++ } ++ ++ out, err := UpdateACL(&tt.input, ACL{Owner: owner}, newOwnerScopeIAM()) ++ if tt.err != nil { ++ assert.Equal(t, tt.err, err) ++ return ++ } ++ if !assert.NoError(t, err) { ++ return ++ } ++ var acl ACL ++ assert.NoError(t, json.Unmarshal(out, &acl)) ++ assert.Equal(t, ACL{Owner: owner, Grantees: tt.want}, acl) ++ }) ++ } ++} ++ ++// TestUpdateACL_PublicGrantRoundTrip writes back, unchanged, the ACL ++// GetBucketAcl reports for a public-read bucket. The all-users grant used to ++// be looked up as an account of that name, failing the request with a 500. ++func TestUpdateACL_PublicGrantRoundTrip(t *testing.T) { ++ stored, err := json.Marshal(ACL{Owner: "owner", Grantees: []Grantee{ ++ {Access: "owner", Permission: PermissionFullControl, Type: types.TypeCanonicalUser}, ++ {Access: "owner-ro", Permission: PermissionRead, Type: types.TypeCanonicalUser}, ++ {Access: "all-users", Permission: PermissionRead, Type: types.TypeGroup}, ++ }}) ++ assert.NoError(t, err) ++ ++ out, err := ParseACLOutput(stored, "owner") ++ assert.NoError(t, err) ++ body, err := xml.Marshal(out) ++ assert.NoError(t, err) ++ ++ var acp AccessControlPolicy ++ assert.NoError(t, xml.Unmarshal(body, &acp)) ++ assert.NoError(t, acp.Validate()) ++ ++ updated, err := UpdateACL(&PutBucketAclInput{AccessControlPolicy: &acp}, ACL{Owner: "owner"}, newOwnerScopeIAM()) ++ assert.NoError(t, err) ++ ++ var acl ACL ++ assert.NoError(t, json.Unmarshal(updated, &acl)) ++ assert.True(t, acl.IsPublic(PermissionRead), "the public read grant survives the round trip") ++ assert.Contains(t, acl.Grantees, Grantee{Access: "owner-ro", Permission: PermissionRead, Type: types.TypeCanonicalUser}) ++} ++ ++// TestVerifyPolicyOwnerScope pins what a bucket policy may name: "*" may be ++// allowed s3:GetObject and s3:ListBucket and nothing else, and every named ++// principal, in an Allow or a Deny, must be one of the owner's keys. ++func TestVerifyPolicyOwnerScope(t *testing.T) { ++ stmt := func(effect, principal, actions, resource string) string { ++ return `{"Effect":"` + effect + `","Principal":` + principal + `,"Action":` + actions + `,"Resource":` + resource + `}` ++ } ++ policy := func(statements ...string) string { ++ doc := `{"Version":"2012-10-17","Statement":[` ++ for i, s := range statements { ++ if i > 0 { ++ doc += "," ++ } ++ doc += s ++ } ++ return doc + `]}` ++ } ++ both := `["arn:aws:s3:::bucket","arn:aws:s3:::bucket/*"]` ++ objects := `"arn:aws:s3:::bucket/*"` ++ oneObject := `"arn:aws:s3:::bucket/shared/report.pdf"` ++ ++ tests := []struct { ++ name string ++ owner string ++ policy string ++ byArn bool ++ err error ++ }{ ++ { ++ name: "public read and list", ++ policy: policy(stmt("Allow", `"*"`, `["s3:GetObject","s3:ListBucket"]`, both)), ++ }, ++ { ++ name: "public read only", ++ policy: policy(stmt("Allow", `{"AWS":"*"}`, `"s3:GetObject"`, objects)), ++ }, ++ { ++ name: "public write", ++ policy: policy(stmt("Allow", `"*"`, `"s3:PutObject"`, objects)), ++ err: publicReadOnlyPolicy(PutObjectAction), ++ }, ++ { ++ name: "public write named the AWS way", ++ policy: policy(stmt("Allow", `{"AWS":["*"]}`, `"s3:PutObject"`, objects)), ++ err: publicReadOnlyPolicy(PutObjectAction), ++ }, ++ { ++ name: "public read alongside a public write", ++ policy: policy(stmt("Allow", `"*"`, `["s3:GetObject","s3:PutObject"]`, objects)), ++ err: publicReadOnlyPolicy(PutObjectAction), ++ }, ++ { ++ name: "public every action", ++ policy: policy(stmt("Allow", `"*"`, `"s3:*"`, both)), ++ err: publicReadOnlyPolicy("s3:*"), ++ }, ++ { ++ name: "public wildcard read", ++ policy: policy(stmt("Allow", `"*"`, `"s3:Get*"`, objects)), ++ err: publicReadOnlyPolicy("s3:Get*"), ++ }, ++ { ++ name: "public version read", ++ policy: policy(stmt("Allow", `"*"`, `"s3:GetObjectVersion"`, objects)), ++ err: publicReadOnlyPolicy(GetObjectVersionAction), ++ }, ++ { ++ name: "public deny of every action grants nothing", ++ policy: policy(stmt("Deny", `"*"`, `"s3:*"`, both)), ++ }, ++ { ++ name: "the owner's other key on one object", ++ policy: policy(stmt("Allow", `{"AWS":["owner-ci"]}`, `["s3:GetObject","s3:PutObject","s3:DeleteObject"]`, oneObject)), ++ }, ++ { ++ name: "the owner itself", ++ policy: policy(stmt("Deny", `{"AWS":"owner"}`, `"s3:*"`, both)), ++ }, ++ { ++ name: "a foreign principal", ++ policy: policy(stmt("Allow", `{"AWS":["foreign"]}`, `"s3:GetObject"`, objects)), ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "a foreign principal beside the owner's key", ++ policy: policy(stmt("Allow", `{"AWS":["owner-ro","foreign"]}`, `"s3:GetObject"`, objects)), ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "a foreign principal in a deny", ++ policy: policy(stmt("Deny", `{"AWS":"foreign"}`, `"s3:GetObject"`, objects)), ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "a foreign principal behind a valid statement", ++ policy: policy( ++ stmt("Allow", `"*"`, `"s3:GetObject"`, objects), ++ stmt("Allow", `{"AWS":"foreign"}`, `"s3:PutObject"`, objects), ++ ), ++ err: notOwnerAccount("foreign", "owner"), ++ }, ++ { ++ name: "an owner with no UserID names nobody else", ++ owner: "nouid", ++ policy: policy(stmt("Allow", `{"AWS":"nouid-2"}`, `"s3:GetObject"`, objects)), ++ err: noOwnerUserID("nouid", "nouid-2"), ++ }, ++ { ++ name: "a root-owned bucket names nobody else", ++ owner: "root", ++ policy: policy(stmt("Allow", `{"AWS":"owner-ci"}`, `"s3:GetObject"`, objects)), ++ err: noOwnerUserID("root", "owner-ci"), ++ }, ++ { ++ name: "ARN principals are the IAM service's to resolve", ++ byArn: true, ++ policy: policy(stmt("Allow", `{"AWS":"arn:aws:iam::000000000000:user/alice"}`, `"s3:GetObject"`, objects)), ++ }, ++ { ++ name: "ARN backends still keep public access read-only", ++ byArn: true, ++ policy: policy(stmt("Allow", `"*"`, `"s3:PutObject"`, objects)), ++ err: publicReadOnlyPolicy(PutObjectAction), ++ }, ++ } ++ ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ owner := tt.owner ++ if owner == "" { ++ owner = "owner" ++ } ++ var iam IAMService = newOwnerScopeIAM() ++ if tt.byArn { ++ iam = &arnOwnerScopeIAM{*newOwnerScopeIAM()} ++ } ++ ++ // Only a document PutBucketPolicy already accepts reaches the ++ // owner-scope check, so hold every case to that first. ++ if !tt.byArn { ++ assert.NoError(t, ValidatePolicyDocument([]byte(tt.policy), "bucket", iam)) ++ } ++ assert.Equal(t, tt.err, VerifyPolicyOwnerScope([]byte(tt.policy), owner, iam)) ++ }) ++ } ++} ++ ++// TestVerifyAccess_GatewayAdminActions pins that deleting a bucket and ++// changing its versioning, object lock or ownership controls are root's and ++// admins' alone: no ownership, ACL grant, policy or public access reaches ++// them. ++func TestVerifyAccess_GatewayAdminActions(t *testing.T) { ++ acl := ACL{Owner: "owner", Grantees: []Grantee{ ++ {Access: "owner", Permission: PermissionFullControl, Type: types.TypeCanonicalUser}, ++ {Access: "owner-ci", Permission: PermissionFullControl, Type: types.TypeCanonicalUser}, ++ }} ++ grantAll := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":["owner-ro"]},"Action":"s3:*","Resource":["arn:aws:s3:::bucket","arn:aws:s3:::bucket/*"]}]}` ++ ++ callers := []struct { ++ name string ++ opts AccessOptions ++ policy string ++ allowed bool ++ }{ ++ {name: "gateway root", opts: AccessOptions{IsRoot: true, Acc: Account{Access: "root", Role: RoleAdmin}}, allowed: true}, ++ {name: "admin account", opts: AccessOptions{Acc: Account{Access: "admin", Role: RoleAdmin}}, allowed: true}, ++ {name: "bucket owner", opts: AccessOptions{Acc: Account{Access: "owner", Role: RoleUserPlus}}}, ++ {name: "FULL_CONTROL grantee", opts: AccessOptions{Acc: Account{Access: "owner-ci", Role: RoleUser}}}, ++ {name: "key a policy grants s3:*", opts: AccessOptions{Acc: Account{Access: "owner-ro", Role: RoleUser}}, policy: grantAll}, ++ {name: "anonymous public request", opts: AccessOptions{IsPublicRequest: true}}, ++ } ++ ++ for _, action := range gatewayAdminActions { ++ for _, caller := range callers { ++ t.Run(string(action)+"/"+caller.name, func(t *testing.T) { ++ var be backend.Backend = noBucketPolicyBackend{} ++ if caller.policy != "" { ++ be = arnPolicyBackend{policy: caller.policy} ++ } ++ opts := caller.opts ++ opts.Acl = acl ++ opts.AclPermission = PermissionWrite ++ opts.Bucket = "bucket" ++ opts.Actions = []Action{action} ++ ++ err := VerifyAccess(testFiberCtx(t), be, opts) ++ if caller.allowed { ++ assert.NoError(t, err) ++ } else { ++ assert.Equal(t, accessDeniedErr(string(action)+" is reserved to the gateway's root and admin accounts"), err) ++ } ++ }) ++ } ++ } ++ ++ t.Run("readonly mode still binds root", func(t *testing.T) { ++ err := VerifyAccess(testFiberCtx(t), noBucketPolicyBackend{}, AccessOptions{ ++ Acl: acl, AclPermission: PermissionWrite, IsRoot: true, Readonly: true, ++ Acc: Account{Access: "root", Role: RoleAdmin}, Bucket: "bucket", Actions: []Action{DeleteBucketAction}, ++ }) ++ assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err) ++ }) ++} ++ ++// TestVerifyAccess_SingleObjectKey is the hand-off case: one of the owner's ++// keys, not a grantee in the ACL, named in a single policy statement ++// allowing s3:GetObject, s3:PutObject and s3:DeleteObject on one object. It ++// may do exactly that and nothing else. ++func TestVerifyAccess_SingleObjectKey(t *testing.T) { ++ const object = "shared/report.pdf" ++ singleObjectPolicy := []byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["owner-ci"]},` + ++ `"Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::bucket/` + object + `"}]}`) ++ acl := ACL{Owner: "owner", Grantees: []Grantee{ ++ {Access: "owner", Permission: PermissionFullControl, Type: types.TypeCanonicalUser}, ++ }} ++ be := bucketPolicyNoLockBackend{policy: singleObjectPolicy} ++ accessDenied := s3err.GetAPIError(s3err.ErrAccessDenied) ++ ++ assert.NoError(t, ValidatePolicyDocument(singleObjectPolicy, "bucket", newOwnerScopeIAM())) ++ assert.NoError(t, VerifyPolicyOwnerScope(singleObjectPolicy, "owner", newOwnerScopeIAM())) ++ ++ tests := []struct { ++ name string ++ object string ++ actions []Action ++ perm Permission ++ want error ++ }{ ++ {name: "reads the object", object: object, actions: []Action{GetObjectAction}, perm: PermissionRead}, ++ {name: "writes the object", object: object, actions: []Action{PutObjectAction}, perm: PermissionWrite}, ++ {name: "deletes the object", object: object, actions: []Action{DeleteObjectAction}, perm: PermissionWrite}, ++ {name: "reads no other object", object: "shared/other.pdf", actions: []Action{GetObjectAction}, perm: PermissionRead, want: accessDenied}, ++ {name: "reads no object below it", object: object + "/x", actions: []Action{GetObjectAction}, perm: PermissionRead, want: accessDenied}, ++ {name: "writes no other object", object: "report.pdf", actions: []Action{PutObjectAction}, perm: PermissionWrite, want: accessDenied}, ++ {name: "deletes no other object", object: object + ".bak", actions: []Action{DeleteObjectAction}, perm: PermissionWrite, want: accessDenied}, ++ {name: "lists nothing", actions: []Action{ListBucketAction}, perm: PermissionRead, want: accessDenied}, ++ {name: "reads no older version", object: object, actions: []Action{GetObjectVersionAction}, perm: PermissionRead, want: accessDenied}, ++ {name: "deletes no version", object: object, actions: []Action{DeleteObjectVersionAction}, perm: PermissionWrite, want: accessDenied}, ++ {name: "tags nothing on upload", object: object, actions: []Action{PutObjectAction, PutObjectTaggingAction}, perm: PermissionWrite, want: accessDenied}, ++ {name: "reads no object ACL", object: object, actions: []Action{GetObjectAclAction}, perm: PermissionReadAcp, want: accessDenied}, ++ {name: "reads no bucket ACL", actions: []Action{GetBucketAclAction}, perm: PermissionReadAcp, want: accessDenied}, ++ {name: "writes no bucket ACL", actions: []Action{PutBucketAclAction}, perm: PermissionWriteAcp, want: accessDenied}, ++ {name: "reads no bucket policy", actions: []Action{GetBucketPolicyAction}, perm: PermissionRead, want: accessDenied}, ++ {name: "writes no bucket policy", actions: []Action{PutBucketPolicyAction}, perm: PermissionWrite, want: accessDenied}, ++ {name: "deletes no bucket", actions: []Action{DeleteBucketAction}, perm: PermissionWrite, ++ want: accessDeniedErr("s3:DeleteBucket is reserved to the gateway's root and admin accounts")}, ++ } ++ ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ ++ Acl: acl, ++ AclPermission: tt.perm, ++ Acc: Account{Access: "owner-ci", Role: RoleUser, UserID: 70001}, ++ Bucket: "bucket", ++ Object: tt.object, ++ Actions: tt.actions, ++ }) ++ assert.Equal(t, tt.want, err) ++ }) ++ } ++ ++ t.Run("deletes only the object from a batch", func(t *testing.T) { ++ errs, err := VerifyObjectsAccess(testFiberCtx(t), be, AccessOptions{ ++ Acl: acl, ++ AclPermission: PermissionWrite, ++ Acc: Account{Access: "owner-ci", Role: RoleUser, UserID: 70001}, ++ Bucket: "bucket", ++ }, deleteObjectIdentifiers(object, "shared/other.pdf"), BypassNone) ++ assert.NoError(t, err) ++ assert.Equal(t, []error{nil, accessDenied}, errs) ++ }) ++} +diff --git a/s3api/controllers/bucket-delete_test.go b/s3api/controllers/bucket-delete_test.go +index 50ae11d..03c2e1f 100644 +--- a/s3api/controllers/bucket-delete_test.go ++++ b/s3api/controllers/bucket-delete_test.go +@@ -19,6 +19,7 @@ import ( + "net/http" + "testing" + ++ "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/s3err" + ) + +@@ -107,7 +108,7 @@ func TestS3ApiController_DeleteBucketOwnershipControls(t *testing.T) { + output testOutput + }{ + { +- name: "verify access fails", ++ name: "verify access fails: reserved to root and admin", + input: testInput{ + locals: accessDeniedLocals, + }, +@@ -117,7 +118,7 @@ func TestS3ApiController_DeleteBucketOwnershipControls(t *testing.T) { + BucketOwner: "root", + }, + }, +- err: s3err.GetAPIError(s3err.ErrAccessDenied), ++ err: gatewayAdminOnlyErr(auth.PutBucketOwnershipControlsAction), + }, + }, + { +@@ -341,7 +342,7 @@ func TestS3ApiController_DeleteBucket(t *testing.T) { + output testOutput + }{ + { +- name: "verify access fails", ++ name: "verify access fails: reserved to root and admin", + input: testInput{ + locals: accessDeniedLocals, + }, +@@ -351,7 +352,7 @@ func TestS3ApiController_DeleteBucket(t *testing.T) { + BucketOwner: "root", + }, + }, +- err: s3err.GetAPIError(s3err.ErrAccessDenied), ++ err: gatewayAdminOnlyErr(auth.DeleteBucketAction), + }, + }, + { +diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go +index 5253c82..63e25f5 100644 +--- a/s3api/controllers/bucket-put.go ++++ b/s3api/controllers/bucket-put.go +@@ -370,6 +370,15 @@ func (c S3ApiController) PutBucketPolicy(ctx fiber.Ctx) (*Response, error) { + }, err + } + ++ err = auth.VerifyPolicyOwnerScope(ctx.BodyRaw(), parsedAcl.Owner, c.iam) ++ if err != nil { ++ return &Response{ ++ MetaOpts: &MetaOptions{ ++ BucketOwner: parsedAcl.Owner, ++ }, ++ }, err ++ } ++ + err = c.be.PutBucketPolicy(ctx.RequestCtx(), bucket, ctx.BodyRaw()) + return &Response{ + MetaOpts: &MetaOptions{ +diff --git a/s3api/controllers/bucket-put_test.go b/s3api/controllers/bucket-put_test.go +index 51f9c38..c6c51fe 100644 +--- a/s3api/controllers/bucket-put_test.go ++++ b/s3api/controllers/bucket-put_test.go +@@ -169,7 +169,7 @@ func TestS3ApiController_PutBucketOwnershipControls(t *testing.T) { + output testOutput + }{ + { +- name: "verify access fails", ++ name: "verify access fails: reserved to root and admin", + input: testInput{ + locals: accessDeniedLocals, + }, +@@ -179,7 +179,7 @@ func TestS3ApiController_PutBucketOwnershipControls(t *testing.T) { + BucketOwner: "root", + }, + }, +- err: s3err.GetAPIError(s3err.ErrAccessDenied), ++ err: gatewayAdminOnlyErr(auth.PutBucketOwnershipControlsAction), + }, + }, + { +@@ -295,7 +295,7 @@ func TestS3ApiController_PutBucketVersioning(t *testing.T) { + output testOutput + }{ + { +- name: "verify access fails", ++ name: "verify access fails: reserved to root and admin", + input: testInput{ + locals: accessDeniedLocals, + }, +@@ -305,7 +305,7 @@ func TestS3ApiController_PutBucketVersioning(t *testing.T) { + BucketOwner: "root", + }, + }, +- err: s3err.GetAPIError(s3err.ErrAccessDenied), ++ err: gatewayAdminOnlyErr(auth.PutBucketVersioningAction), + }, + }, + { +@@ -401,7 +401,7 @@ func TestS3ApiController_PutObjectLockConfiguration(t *testing.T) { + output testOutput + }{ + { +- name: "verify access fails", ++ name: "verify access fails: reserved to root and admin", + input: testInput{ + locals: accessDeniedLocals, + }, +@@ -411,7 +411,7 @@ func TestS3ApiController_PutObjectLockConfiguration(t *testing.T) { + BucketOwner: "root", + }, + }, +- err: s3err.GetAPIError(s3err.ErrAccessDenied), ++ err: gatewayAdminOnlyErr(auth.PutBucketObjectLockConfigurationAction), + }, + }, + { +diff --git a/s3api/controllers/owner_scope_test.go b/s3api/controllers/owner_scope_test.go +new file mode 100644 +index 0000000..e96eb35 +--- /dev/null ++++ b/s3api/controllers/owner_scope_test.go +@@ -0,0 +1,222 @@ ++// Copyright 2026 s3-gateway contributors ++// This file is licensed under the Apache License, Version 2.0 ++// (the "License"); you may not use this file except in compliance ++// with the License. You may obtain a copy of the License at ++// ++// http://www.apache.org/licenses/LICENSE-2.0 ++// ++// Unless required by applicable law or agreed to in writing, ++// software distributed under the License is distributed on an ++// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY ++// KIND, either express or implied. See the License for the ++// specific language governing permissions and limitations ++// under the License. ++ ++package controllers ++ ++import ( ++ "context" ++ "encoding/json" ++ "net/http" ++ "testing" ++ ++ "github.com/aws/aws-sdk-go-v2/service/s3" ++ "github.com/aws/aws-sdk-go-v2/service/s3/types" ++ "github.com/stretchr/testify/assert" ++ "github.com/versity/versitygw/auth" ++ "github.com/versity/versitygw/s3api/utils" ++ "github.com/versity/versitygw/s3err" ++) ++ ++// gatewayAdminOnlyErr is how auth refuses an action reserved to the ++// gateway's root and admin accounts. ++func gatewayAdminOnlyErr(action auth.Action) error { ++ return s3err.APIError{ ++ Code: "AccessDenied", ++ Description: string(action) + " is reserved to the gateway's root and admin accounts", ++ HTTPStatusCode: http.StatusForbidden, ++ } ++} ++ ++func ownerScopeDenied(description string) error { ++ return s3err.APIError{ ++ Code: "AccessDenied", ++ Description: description, ++ HTTPStatusCode: http.StatusForbidden, ++ } ++} ++ ++// ownerScopeIAMMock knows the bucket owner "owner", its second key ++// "owner-ci" on the same UserID, and "foreign" on another. ++func ownerScopeIAMMock() *IAMServiceMock { ++ accounts := map[string]auth.Account{ ++ "owner": {Access: "owner", Role: auth.RoleUserPlus, UserID: 70001, GroupID: 70001}, ++ "owner-ci": {Access: "owner-ci", Role: auth.RoleUser, UserID: 70001, GroupID: 70001}, ++ "foreign": {Access: "foreign", Role: auth.RoleUser, UserID: 70002, GroupID: 70002}, ++ } ++ get := func(access string) (auth.Account, error) { ++ acc, ok := accounts[access] ++ if !ok { ++ return auth.Account{}, auth.ErrNoSuchUser ++ } ++ return acc, nil ++ } ++ return &IAMServiceMock{ ++ GetUserAccountFunc: get, ++ ResolveAccountsFunc: func(accessKeyIDs []string) ([]string, error) { ++ missing := []string{} ++ for _, access := range accessKeyIDs { ++ if _, err := get(access); err != nil { ++ missing = append(missing, access) ++ } ++ } ++ return missing, nil ++ }, ++ } ++} ++ ++// TestOwnerScope_RootIsRefusedToo drives each way of naming an account — ++// PutBucketPolicy, PutBucketAcl's headers and body, and the admin API's ++// create-bucket — as the gateway root, on a bucket owned by "owner". Root ++// gets past every authorization check, and is still refused a foreign key ++// or a public write, while the owner's own keys go through. ++func TestOwnerScope_RootIsRefusedToo(t *testing.T) { ++ rootLocals := map[utils.ContextKey]any{ ++ utils.ContextKeyIsRoot: true, ++ utils.ContextKeyParsedAcl: auth.ACL{Owner: "owner"}, ++ utils.ContextKeyAccount: auth.Account{Access: "root", Role: auth.RoleAdmin}, ++ utils.ContextKeyRegion: "us-east-1", ++ } ++ policy := func(principal, action string) []byte { ++ return []byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":` + principal + ++ `,"Action":"` + action + `","Resource":"arn:aws:s3:::bucket/*"}]}`) ++ } ++ aclBody := func(grantee, granteeType, permission string) []byte { ++ return []byte(`owner` + ++ `` + ++ `` + grantee + `` + permission + ``) ++ } ++ notOwners := ownerScopeDenied("foreign is not an account of the bucket owner owner") ++ publicWrite := ownerScopeDenied("public access is read-only: all-users cannot be granted WRITE") ++ ownerMeta := &Response{MetaOpts: &MetaOptions{BucketOwner: "owner"}} ++ ++ t.Run("PutBucketPolicy", func(t *testing.T) { ++ tests := []struct { ++ name string ++ body []byte ++ err error ++ }{ ++ {name: "names the owner's other key", body: policy(`{"AWS":"owner-ci"}`, "s3:PutObject")}, ++ {name: "names a foreign key", body: policy(`{"AWS":"foreign"}`, "s3:GetObject"), err: notOwners}, ++ {name: "allows the public a write", body: policy(`"*"`, "s3:PutObject"), ++ err: ownerScopeDenied(`public access is read-only: a policy may allow "*" only s3:GetObject and s3:ListBucket, not s3:PutObject`)}, ++ {name: "allows the public a read", body: policy(`"*"`, "s3:GetObject")}, ++ } ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ stored := false ++ ctrl := S3ApiController{ ++ be: &BackendMock{ ++ PutBucketPolicyFunc: func(context.Context, string, []byte) error { ++ stored = true ++ return nil ++ }, ++ }, ++ iam: ownerScopeIAMMock(), ++ } ++ resp := ownerMeta ++ if tt.err == nil { ++ resp = &Response{MetaOpts: &MetaOptions{BucketOwner: "owner", Status: http.StatusNoContent}} ++ } ++ testController(t, ctrl.PutBucketPolicy, resp, tt.err, ctxInputs{locals: rootLocals, body: tt.body}) ++ assert.Equal(t, tt.err == nil, stored, "a refused policy is never stored") ++ }) ++ } ++ }) ++ ++ t.Run("PutBucketAcl", func(t *testing.T) { ++ tests := []struct { ++ name string ++ headers map[string]string ++ body []byte ++ err error ++ }{ ++ {name: "grant header names the owner's other key", headers: map[string]string{"X-Amz-Grant-Full-Control": "owner-ci"}}, ++ {name: "grant header names a foreign key", headers: map[string]string{"X-Amz-Grant-Read": "foreign"}, err: notOwners}, ++ {name: "body names a foreign key", body: aclBody("foreign", "CanonicalUser", "FULL_CONTROL"), err: notOwners}, ++ {name: "body writes back the public read grant", body: aclBody("all-users", "Group", "READ")}, ++ {name: "body grants the public a write", body: aclBody("all-users", "Group", "WRITE"), err: publicWrite}, ++ {name: "canned public-read-write", headers: map[string]string{"X-Amz-Acl": "public-read-write"}, err: publicWrite}, ++ } ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ var stored []byte ++ ctrl := S3ApiController{ ++ be: &BackendMock{ ++ GetBucketOwnershipControlsFunc: func(context.Context, string) (types.ObjectOwnership, error) { ++ return types.ObjectOwnershipBucketOwnerPreferred, nil ++ }, ++ PutBucketAclFunc: func(_ context.Context, _ string, data []byte) error { ++ stored = data ++ return nil ++ }, ++ }, ++ iam: ownerScopeIAMMock(), ++ } ++ testController(t, ctrl.PutBucketAcl, ownerMeta, tt.err, ctxInputs{locals: rootLocals, headers: tt.headers, body: tt.body}) ++ if tt.err != nil { ++ assert.Nil(t, stored, "a refused ACL is never stored") ++ return ++ } ++ var acl auth.ACL ++ assert.NoError(t, json.Unmarshal(stored, &acl)) ++ assert.Equal(t, "owner", acl.Owner) ++ }) ++ } ++ }) ++ ++ t.Run("admin create-bucket", func(t *testing.T) { ++ tests := []struct { ++ name string ++ grant string ++ err error ++ }{ ++ {name: "grants the owner's other key", grant: "owner-ci"}, ++ {name: "grants a foreign key", grant: "foreign", err: notOwners}, ++ {name: "grants a foreign key among the owner's", grant: "owner-ci,foreign", err: notOwners}, ++ } ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ created := false ++ iam := ownerScopeIAMMock() ++ be := &BackendMock{ ++ CreateBucketFunc: func(context.Context, *s3.CreateBucketInput, []byte) error { ++ created = true ++ return nil ++ }, ++ } ++ ctrl := AdminController{ ++ iam: iam, ++ be: be, ++ s3api: New(be, iam, nil, nil, nil, false, false, "", 10000, auth.ListBucketsScopeOwned), ++ } ++ resp := &Response{MetaOpts: &MetaOptions{}} ++ if tt.err == nil { ++ resp.MetaOpts.Status = http.StatusCreated ++ } ++ testController(t, ctrl.CreateBucket, resp, tt.err, ctxInputs{ ++ locals: map[utils.ContextKey]any{ ++ utils.ContextKeyIsRoot: true, ++ utils.ContextKeyAccount: auth.Account{Access: "root", Role: auth.RoleAdmin}, ++ }, ++ headers: map[string]string{ ++ "x-vgw-owner": "owner", ++ "X-Amz-Object-Ownership": string(types.ObjectOwnershipBucketOwnerPreferred), ++ "X-Amz-Grant-Full-Control": tt.grant, ++ }, ++ }) ++ assert.Equal(t, tt.err == nil, created, "a refused bucket is never created") ++ }) ++ } ++ }) ++} +-- +2.49.0.windows.1 + diff --git a/tests/owner-scope/proof.sh b/tests/owner-scope/proof.sh new file mode 100644 index 0000000..5e707e5 --- /dev/null +++ b/tests/owner-scope/proof.sh @@ -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 -- one request as ("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 +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 +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 + doc acl.xml "po-owner$1$3" +} +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 "BucketOwnerPreferred") +VER=$(doc ver.xml "Enabled") +LCK=$(doc lck.xml "Enabled") +# 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 'all-users.*READ' "$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 ] diff --git a/tests/owner-scope/s3req.py b/tests/owner-scope/s3req.py new file mode 100644 index 0000000..2ad0e1a --- /dev/null +++ b/tests/owner-scope/s3req.py @@ -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"(.*?)", data) + msg = re.search(rb"(.*?)", 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:])