ci: the contract fixture's policy has to be the policy
30-minio-contract hand-writes the scoped user's policy, and its own header already said why that is dangerous — nothing checks it against a second copy, so loosening it is invisible. That was written about granting the denied bucket by accident. It applies harder now. The contract test no longer only asks "does an S3 client work against a real server". It asserts a SECURITY PROPERTY OF THE POLICY: that an app cannot tag its own object by either route. A fixture policy missing the two Deny statements would fail the test for the right reason — and a fixture policy that drifted the other way, quietly regaining what storePolicyJSON denies, would let a real regression through while staying green. So the fixture gains both Denys, and the header gains the rule that it must track storePolicyJSON, with the reason the second statement is the load-bearing one: leaving s3:PutObjectTagging out of an Allow does not stop an app tagging its own object, because x-amz-tagging on PutObject sets tags without naming that action. MinIO let the tagged upload through until the s3:RequestObjectTagKeys Deny was added. Verified by running this exact step, not an approximation of it: minio contract: PASS, with the parent PASS line the step insists on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEkJh1ESqiA84quAHJV4K6
1 file changed,
+23
−8
.amadan/ci.d/30-minio-contract+23 −8
diff --git a/.amadan/ci.d/30-minio-contract b/.amadan/ci.d/30-minio-contract| index 37cac5ee0..7c2ab4d5b 100755 |
| --- a/.amadan/ci.d/30-minio-contract |
| +++ b/.amadan/ci.d/30-minio-contract |
| @@ -249,20 +249,35 @@ echo " minio healthy on 127.0.0.1:$port" |
| # --------------------------------------------------------------------- |
| # The fixture: two buckets and a scoped user granted exactly one of them. |
| # |
| -# Carried over verbatim from the workflow's `mint scoped user` step — |
| -# same bucket names, same policy, same user — because the whole value of |
| -# the test is that cross-bucket denial is enforced by real IAM rather |
| -# than by the client politely not asking. Nothing checks this policy |
| -# against a second copy any more, so loosening it here is invisible: |
| -# grant `denied-bucket` by accident and the test still passes while |
| -# proving nothing. Change it only on purpose. |
| +# Carried over from the workflow's `mint scoped user` step — same bucket |
| +# names, same user — because the whole value of the test is that |
| +# cross-bucket denial is enforced by real IAM rather than by the client |
| +# politely not asking. Nothing checks this policy against a second copy, |
| +# so loosening it here is invisible: grant `denied-bucket` by accident |
| +# and the test still passes while proving nothing. Change it only on |
| +# purpose. |
| +# |
| +# THIS DOCUMENT MUST TRACK storePolicyJSON (internal/objstore/aws.go). |
| +# It is a hand-written copy of what the console writes for a real store, |
| +# and the test now asserts security properties OF THE POLICY — not just |
| +# that an S3 client works. The two Deny statements are the anti-forgery |
| +# pair, and the second one is the load-bearing one: leaving |
| +# s3:PutObjectTagging out of the Allow does NOT stop an app tagging its |
| +# own object, because x-amz-tagging on PutObject sets tags without ever |
| +# naming that action. MinIO let the tagged upload through until the |
| +# s3:RequestObjectTagKeys Deny was added. Drop either statement here and |
| +# the test goes red, which is the point: this file is where the property |
| +# is checked against a real server rather than argued about. |
| "$mc" --quiet alias set local "http://127.0.0.1:$port" "$root_user" "$root_pass" |
| "$mc" --quiet mb local/contract-bucket local/denied-bucket |
| cat >"$rundir/policy.json" <<'EOF' |
| {"Version": "2012-10-17", "Statement": [ |
| {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::contract-bucket"]}, |
| - {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"]} |
| + {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:GetObjectTagging"], "Resource": ["arn:aws:s3:::contract-bucket/*"]}, |
| + {"Effect": "Deny", "Action": ["s3:PutObjectTagging", "s3:DeleteObjectTagging"], "Resource": ["arn:aws:s3:::contract-bucket/*"]}, |
| + {"Effect": "Deny", "Action": ["s3:PutObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"], |
| + "Condition": {"ForAnyValue:StringEquals": {"s3:RequestObjectTagKeys": ["carlos-scan", "carlos-scan-version", "carlos-scan-at"]}}} |
| ]} |
| EOF |