diff --git a/policy/action.go b/policy/action.go
index 15d5178..c19744c 100644
--- a/policy/action.go
+++ b/policy/action.go
@@ -398,6 +398,9 @@ func (a ActionConditionKeyMap) Lookup(action Action) condition.KeySet {
}
ckeysMerged := condition.NewKeySet(commonKeys...)
+ if !bucketlessActions[action] {
+ ckeysMerged.Merge(bucketTagKeys)
+ }
for act, ckey := range a {
if action.Match(act) {
ckeysMerged.Merge(ckey)
@@ -406,6 +409,19 @@ func (a ActionConditionKeyMap) Lookup(action Action) condition.KeySet {
return ckeysMerged
}
+// bucketTagKeys are the keys every S3 action that acts on an existing bucket
+// takes, read from that bucket's tags.
+var bucketTagKeys = condition.NewKeySet(
+ condition.AWSResourceTag.ToKey(),
+ condition.S3BucketTag.ToKey(),
+)
+
+// bucketlessActions act on no existing bucket, so no bucket tags reach them.
+var bucketlessActions = map[Action]bool{
+ ListAllMyBucketsAction: true,
+ CreateBucketAction: true,
+}
+
// IAMActionConditionKeyMap - holds mapping of supported condition key for an action.
var IAMActionConditionKeyMap = createActionConditionKeyMap()
@@ -553,6 +569,8 @@ func createActionConditionKeyMap() ActionConditionKeyMap {
append([]condition.Key{
condition.RequestObjectTagKeys.ToKey(),
condition.RequestObjectTag.ToKey(),
+ condition.AWSRequestTag.ToKey(),
+ condition.AWSTagKeys.ToKey(),
}, commonKeys...)...),
PutObjectTaggingAction: condition.NewKeySet(
append([]condition.Key{
diff --git a/policy/bucket_tag_condition_test.go b/policy/bucket_tag_condition_test.go
new file mode 100644
index 0000000..66dda09
--- /dev/null
+++ b/policy/bucket_tag_condition_test.go
@@ -0,0 +1,113 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package policy
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/minio/pkg/v3/policy/condition"
+)
+
+// TestBucketTagConditions verifies that aws:ResourceTag/ and
+// s3:BucketTag/ condition bucket and object actions on the tags of the
+// bucket, and that aws:RequestTag/ and aws:TagKeys condition a bucket tag
+// write on the tags it sets.
+func TestBucketTagConditions(t *testing.T) {
+ doc := `{"Version":"2012-10-17","Statement":[
+ {"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]},
+ {"Effect":"Deny","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::*"],
+ "Condition":{"StringEquals":{"aws:ResourceTag/level":["restricted"]}}},
+ {"Effect":"Deny","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::*"],
+ "Condition":{"StringEquals":{"s3:BucketTag/state":["frozen"]}}},
+ {"Effect":"Deny","Action":["s3:PutBucketTagging"],"Resource":["arn:aws:s3:::*"],
+ "Condition":{"ForAnyValue:StringLike":{"aws:TagKeys":["fabric.governance.*"]}}},
+ {"Effect":"Deny","Action":["s3:PutBucketTagging"],"Resource":["arn:aws:s3:::*"],
+ "Condition":{"StringEquals":{"aws:RequestTag/tier":["gold"]}}}
+]}`
+ p, err := ParseConfig(strings.NewReader(doc))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := p.CheckVariables(); err != nil {
+ t.Fatal(err)
+ }
+
+ args := func(action Action, object string, bucketTags, requestTags map[string]string) Args {
+ values := map[string][]string{}
+ for k, v := range bucketTags {
+ values[condition.NewKey(condition.AWSResourceTag, k).Name()] = []string{v}
+ values[condition.NewKey(condition.S3BucketTag, k).Name()] = []string{v}
+ }
+ var keys []string
+ for k, v := range requestTags {
+ keys = append(keys, k)
+ values[condition.NewKey(condition.AWSRequestTag, k).Name()] = []string{v}
+ }
+ if requestTags != nil {
+ values[condition.AWSTagKeys.Name()] = keys
+ }
+ return Args{AccountName: "user", Action: action, BucketName: "bucket", ObjectName: object, ConditionValues: values}
+ }
+ restricted := map[string]string{"level": "restricted"}
+
+ for _, tc := range []struct {
+ name string
+ args Args
+ want bool
+ }{
+ {"read in a restricted bucket", args(GetObjectAction, "obj", restricted, nil), false},
+ {"list a restricted bucket", args(ListBucketAction, "", restricted, nil), false},
+ {"read in a public bucket", args(GetObjectAction, "obj", map[string]string{"level": "public"}, nil), true},
+ {"read in an untagged bucket", args(GetObjectAction, "obj", nil, nil), true},
+ {"write in a restricted bucket", args(PutObjectAction, "obj", restricted, nil), true},
+ {"write in a frozen bucket", args(PutObjectAction, "obj", map[string]string{"state": "frozen"}, nil), false},
+ {"tag a bucket with a free key", args(PutBucketTaggingAction, "", nil, map[string]string{"team": "x"}), true},
+ {"tag a bucket with a governed key", args(PutBucketTaggingAction, "", nil, map[string]string{"team": "x", "fabric.governance.level": "public"}), false},
+ {"remove every bucket tag", args(PutBucketTaggingAction, "", nil, map[string]string{}), true},
+ {"tag a bucket with a denied value", args(PutBucketTaggingAction, "", nil, map[string]string{"tier": "gold"}), false},
+ } {
+ if got := p.IsAllowed(tc.args); got != tc.want {
+ t.Errorf("%s: IsAllowed = %v, want %v", tc.name, got, tc.want)
+ }
+ }
+
+ for _, accepted := range []string{
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"StringEquals":{"aws:ResourceTag/level":["restricted"]}}}]}`,
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":["s3:DeleteBucket"],"Resource":["arn:aws:s3:::bucket"],"Condition":{"StringEquals":{"s3:BucketTag/level":["restricted"]}}}]}`,
+ } {
+ if _, err := ParseConfig(strings.NewReader(accepted)); err != nil {
+ t.Errorf("a bucket tag key on an S3 action must be accepted: %v: %s", err, accepted)
+ }
+ if _, err := ParseBucketPolicyConfig(strings.NewReader(strings.Replace(accepted, `"Action"`, `"Principal":"*","Action"`, 1)), "bucket"); err != nil {
+ t.Errorf("a bucket tag key in a bucket policy must be accepted: %v: %s", err, accepted)
+ }
+ }
+
+ for _, refused := range []string{
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"ForAnyValue:StringEquals":{"aws:TagKeys":["x"]}}}]}`,
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"StringEquals":{"aws:RequestTag/x":["y"]}}}]}`,
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3tables:GetTable"],"Resource":["arn:aws:s3tables:::bucket/*"],"Condition":{"StringEquals":{"aws:ResourceTag/x":["y"]}}}]}`,
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListAllMyBuckets"],"Resource":["arn:aws:s3:::*"],"Condition":{"StringEquals":{"aws:ResourceTag/x":["y"]}}}]}`,
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:CreateBucket"],"Resource":["arn:aws:s3:::bucket"],"Condition":{"StringEquals":{"s3:BucketTag/x":["y"]}}}]}`,
+ } {
+ if _, err := ParseConfig(strings.NewReader(refused)); err == nil {
+ t.Errorf("must be refused: %s", refused)
+ }
+ }
+}
diff --git a/policy/condition/keyname.go b/policy/condition/keyname.go
index 5fff4dc..09b0b7d 100644
--- a/policy/condition/keyname.go
+++ b/policy/condition/keyname.go
@@ -76,6 +76,8 @@ var variableKeys = map[KeyName]bool{
S3TablesWarehouseTag: true,
S3TablesTableTag: true,
AWSRequestTag: true,
+ AWSResourceTag: true,
+ S3BucketTag: true,
}
func variableKeyNames() []string {
@@ -261,6 +263,15 @@ const (
// AWSTagKeys filters a tagging request by the tag keys it sets or removes.
AWSTagKeys KeyName = "aws:TagKeys"
+ // AWSResourceTag filters access by the value of one tag on the bucket the
+ // request acts on, or holds the object it acts on, as in
+ // aws:ResourceTag/.
+ AWSResourceTag KeyName = "aws:ResourceTag"
+
+ // S3BucketTag filters access by the value of one tag on the bucket the
+ // request is authorized against, as in s3:BucketTag/.
+ S3BucketTag KeyName = "s3:BucketTag"
+
// S3SignatureVersion - identifies the version of AWS Signature that you want to support for authenticated requests.
S3SignatureVersion KeyName = "s3:signatureversion"
@@ -417,6 +428,8 @@ var AllSupportedKeys = []KeyName{
AWSSourceIdentity,
AWSRequestTag,
AWSTagKeys,
+ AWSResourceTag,
+ S3BucketTag,
LDAPUser,
LDAPUsername,
LDAPGroups,
diff --git a/policy/condition_variable_test.go b/policy/condition_variable_test.go
index a1c5d88..b853a27 100644
--- a/policy/condition_variable_test.go
+++ b/policy/condition_variable_test.go
@@ -84,6 +84,9 @@ func TestConditionVariableAllowedOnTagKeys(t *testing.T) {
{"s3tables:TableTag/team", `"s3tables:GetTable"`, `"arn:aws:s3tables:::bucket/*"`},
{"s3tables:WarehouseTag/team", `"s3tables:GetTable"`, `"arn:aws:s3tables:::bucket/*"`},
{"aws:RequestTag/team", `"s3tables:TagTable"`, `"arn:aws:s3tables:::bucket/*"`},
+ {"aws:ResourceTag/team", `"s3:GetObject"`, `"arn:aws:s3:::bucket/*"`},
+ {"s3:BucketTag/team", `"s3:ListBucket"`, `"arn:aws:s3:::bucket"`},
+ {"aws:RequestTag/team", `"s3:PutBucketTagging"`, `"arn:aws:s3:::bucket"`},
} {
doc := `{"Version": "2012-10-17", "Statement": [` + conditionStatement(tc.actions, tc.resources, tc.key) + `]}`
p, err := ParseConfig(strings.NewReader(doc))