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>
11 KiB
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, 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:
- "
verifyResourceAccessskips the ACL once a policy exists" — true at4dc0debf, not in v1.11.0. There a policy statement that does not match falls back to the ACL, and a policyDenywins, 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. - "
verifyBucketPolicygives the owner no exemption" — v1.11.0 exempts the owner only for reading, replacing and deleting the policy itself (isBucketPolicyAction). ADenynaming the owner, or"*", still locks the owner out of everything else. 0003 widens the exemption to every action and removesisBucketPolicyAction. PutBucketOwnershipControls,PutBucketVersioningandPutObjectLockConfigurationlive ins3api/controllers/bucket-put.goas stated;DeleteBucketandDeleteBucketOwnershipControlsare inbucket-delete.go. The gate is inauth, so the controllers are unchanged.
Edges worth knowing
- ARN principals (the standalone IAM service,
PrincipalResolver) skip the per-principalUserIDcheck: that backend fixes every bucket's owner to root and gives every identity root's configuredUserID, 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: …, a500); 0003 only adds refusals. - Writing
user.acldirectly (leil-cluster'sleilfs-userappends same-UID grantees withsetfattr) 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
userpluscreator 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):
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):
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.