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))