carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev a9941d06522933bedb7deb9bc2424ee79a49badf parent 2697e42
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