Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions policy/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ func createActionConditionKeyMap() ActionConditionKeyMap {

allSupportedKeys := []condition.Key{}
for _, keyName := range condition.AllSupportedKeys {
// Admin keys describe admin API requests; an S3 request never carries them.
if keyName.IsAdmin() {
continue
}
allSupportedKeys = append(allSupportedKeys, keyName.ToKey())
}

Expand Down
221 changes: 221 additions & 0 deletions policy/admin_policy_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// 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 <http://www.gnu.org/licenses/>.

package policy

import (
"strings"
"testing"
)

// A policy admin can be limited to a set of policies by name.
func TestAdminPolicyNameCondition(t *testing.T) {
doc := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["admin:CreatePolicy", "admin:DeletePolicy", "admin:GetPolicy"],
"Condition": {"StringLike": {"admin:PolicyName": ["app-*", "app-system"]}}
}]
}`
p, err := ParseConfig(strings.NewReader(doc))
if err != nil {
t.Fatalf("a policy naming admin:PolicyName must parse: %v", err)
}

cases := []struct {
action AdminAction
policy []string
allowed bool
}{
{CreatePolicyAdminAction, []string{"app-42"}, true},
{DeletePolicyAdminAction, []string{"app-system"}, true},
{GetPolicyAdminAction, []string{"app-7"}, true},
{CreatePolicyAdminAction, []string{"consoleAdmin"}, false},
{CreatePolicyAdminAction, []string{"app-"}, true},
{CreatePolicyAdminAction, nil, false},
{CreateUserAdminAction, []string{"app-42"}, false},
}
for _, tc := range cases {
values := map[string][]string{}
if tc.policy != nil {
values["PolicyName"] = tc.policy
}
got := p.IsAllowed(Args{
AccountName: "orb",
Action: Action(tc.action),
ConditionValues: values,
})
if got != tc.allowed {
t.Errorf("%s on %v: allowed=%v, want %v", tc.action, tc.policy, got, tc.allowed)
}
}
}

// A Deny scoped by name overrides a wider Allow.
func TestAdminPolicyNameConditionDeny(t *testing.T) {
doc := `{
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Action": ["admin:CreatePolicy"]},
{"Effect": "Deny", "Action": ["admin:CreatePolicy"],
"Condition": {"StringEquals": {"admin:PolicyName": ["consoleAdmin"]}}}
]
}`
p, err := ParseConfig(strings.NewReader(doc))
if err != nil {
t.Fatal(err)
}
allowed := func(name string) bool {
return p.IsAllowed(Args{
AccountName: "orb",
Action: Action(CreatePolicyAdminAction),
ConditionValues: map[string][]string{"PolicyName": {name}},
})
}
if allowed("consoleAdmin") {
t.Error("the Deny must win for consoleAdmin")
}
if !allowed("app-1") {
t.Error("the Allow must still hold for other names")
}
}

// admin:PolicyName describes an admin API request, so no S3 statement may use it.
func TestAdminPolicyNameRefusedOnS3Actions(t *testing.T) {
for _, action := range []string{"s3:*", "s3:GetObject", "s3:PutObject"} {
doc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["` + action + `"],
"Resource": ["arn:aws:s3:::bucket/*"],
"Condition": {"StringLike": {"admin:PolicyName": ["app-*"]}}}]}`
if _, err := ParseConfig(strings.NewReader(doc)); err == nil {
t.Errorf("%s with admin:PolicyName must be refused", action)
}
}
}

// scopedPolicyAdmin is an identity allowed to manage the app-* policies only.
const scopedPolicyAdmin = `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["admin:CreatePolicy", "admin:DeletePolicy", "admin:GetPolicy"],
"Condition": {"StringLike": {"admin:PolicyName": ["app-*"]}}
}]
}`

func allowedPolicyAdmin(t *testing.T, doc string, action AdminAction, values map[string][]string) bool {
t.Helper()
p, err := ParseConfig(strings.NewReader(doc))
if err != nil {
t.Fatal(err)
}
return p.IsAllowed(Args{AccountName: "orb", Action: Action(action), ConditionValues: values})
}

// Only the server sets admin:PolicyName. A request header reaches condition
// values under its canonical form, Policyname, which other keys fall back to;
// an admin key never does, so a header cannot name a policy.
func TestAdminPolicyNameIgnoresHeaderForm(t *testing.T) {
for _, values := range []map[string][]string{
{"Policyname": {"app-1"}},
{"policyname": {"app-1"}},
{"POLICYNAME": {"app-1"}},
{"Policy-Name": {"app-1"}},
} {
if allowedPolicyAdmin(t, scopedPolicyAdmin, CreatePolicyAdminAction, values) {
t.Errorf("%v must not satisfy admin:PolicyName", values)
}
}
// The value the server set wins over a header form alongside it.
values := map[string][]string{"PolicyName": {"consoleAdmin"}, "Policyname": {"app-1"}}
if allowedPolicyAdmin(t, scopedPolicyAdmin, CreatePolicyAdminAction, values) {
t.Error("a header form must not override the server's PolicyName")
}
}

// Names that only resemble a granted one do not match it.
func TestAdminPolicyNameLookalikes(t *testing.T) {
for _, name := range []string{
"APP-1", // case differs
"App-1", // case differs
"xapp-1", // prefix before the pattern
" app-1", // leading space
"*", // a wildcard is a name here, not a pattern
"app", // shorter than the pattern's literal part
"consoleAdmin", // unrelated
"", // empty
"app-1", // full-width letters
} {
if allowedPolicyAdmin(t, scopedPolicyAdmin, CreatePolicyAdminAction, map[string][]string{"PolicyName": {name}}) {
t.Errorf("PolicyName %q must not match app-*", name)
}
}
}

// A condition key spelled any other way is not admin:PolicyName: the policy
// is refused rather than parsed into a condition nothing satisfies or, worse,
// one something else satisfies.
func TestAdminPolicyNameMisspellingsRefused(t *testing.T) {
for _, key := range []string{"Admin:PolicyName", "admin:policyname", "admin:Policyname", "ADMIN:POLICYNAME", "admin:PolicyName ", "aws:PolicyName", "s3:PolicyName"} {
doc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow",
"Action": ["admin:CreatePolicy"],
"Condition": {"StringLike": {"` + key + `": ["app-*"]}}}]}`
if _, err := ParseConfig(strings.NewReader(doc)); err == nil {
t.Errorf("condition key %q must be refused", key)
}
}
}

// admin:PolicyName is refused on every non-admin action family, and a
// statement may not mix admin actions with others to carry it along.
func TestAdminPolicyNameRefusedOutsideAdmin(t *testing.T) {
for _, actions := range []string{
`"s3tables:*"`,
`"s3tables:CreateTable"`,
`"sts:AssumeRole"`,
`"admin:CreatePolicy", "s3:GetObject"`,
} {
doc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": [` + actions + `],
"Resource": ["*"],
"Condition": {"StringLike": {"admin:PolicyName": ["app-*"]}}}]}`
if _, err := ParseConfig(strings.NewReader(doc)); err == nil {
t.Errorf("actions %s with admin:PolicyName must be refused", actions)
}
}
}

// NotAction cannot widen the scoped grant: a statement allowing every admin
// action but CreateUser, under the same condition, still leaves other policies
// out of reach.
func TestAdminPolicyNameNotAction(t *testing.T) {
doc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow",
"NotAction": ["admin:CreateUser"],
"Resource": ["arn:aws:s3:::*"],
"Condition": {"StringLike": {"admin:PolicyName": ["app-*"]}}}]}`
p, err := ParseConfig(strings.NewReader(doc))
if err != nil {
t.Fatal(err)
}
if !p.IsAllowed(Args{AccountName: "orb", Action: Action(CreatePolicyAdminAction), ConditionValues: map[string][]string{"PolicyName": {"app-1"}}}) {
t.Error("NotAction must still allow the granted app-1")
}
for _, name := range []string{"consoleAdmin", "readwrite"} {
if p.IsAllowed(Args{AccountName: "orb", Action: Action(CreatePolicyAdminAction), ConditionValues: map[string][]string{"PolicyName": {name}}}) {
t.Errorf("NotAction must not allow creating %s", name)
}
}
}
36 changes: 28 additions & 8 deletions policy/condition/keyname.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,23 @@ import (
// for more information about available condition keys.
type KeyName string

// adminKeyPrefix is the service prefix of the keys that describe an admin API
// request, such as admin:PolicyName. The server sets their values itself, so a
// policy may use them on admin actions only, and no request header supplies
// them.
const adminKeyPrefix = "admin"

// Prefixes to trim from key names.
var toTrim = map[string]bool{
"aws": true,
"jwt": true,
"ldap": true,
"sts": true,
"svc": true,
"s3": true,
"s3tables": true,
"memory": true,
"aws": true,
"jwt": true,
"ldap": true,
"sts": true,
"svc": true,
"s3": true,
"s3tables": true,
"memory": true,
adminKeyPrefix: true,
}

// Name - returns the key name with its service prefix stripped, so a key reads
Expand All @@ -51,6 +58,11 @@ func (key KeyName) Name() string {
return string(key[idx+1:])
}

// IsAdmin reports whether key describes an admin API request.
func (key KeyName) IsAdmin() bool {
return strings.HasPrefix(string(key), adminKeyPrefix+":")
}

// VarName - returns variable key name, such as "${aws:username}"
func (key KeyName) VarName() string {
return fmt.Sprintf("${%s}", key)
Expand Down Expand Up @@ -274,6 +286,12 @@ const (

// SVCDurationSeconds - Duration seconds condition for Admin policy
SVCDurationSeconds KeyName = "svc:DurationSeconds"

// AdminPolicyName - the name of the policy a policy admin action
// (admin:CreatePolicy, admin:DeletePolicy, admin:GetPolicy) works on, so a
// statement can grant those actions on a set of policies, for example
// StringLike {"admin:PolicyName": ["app-*"]}.
AdminPolicyName KeyName = "admin:PolicyName"
)

// JWTKeys - Supported JWT keys, non-exhaustive list please
Expand Down Expand Up @@ -377,6 +395,7 @@ var AllSupportedKeys = []KeyName{
JWTClientID,
STSDurationSeconds,
SVCDurationSeconds,
AdminPolicyName,
}

// CommonKeys - is list of all common condition keys.
Expand Down Expand Up @@ -428,6 +447,7 @@ var AllSupportedAdminKeys = append([]KeyName{
LDAPUsername,
LDAPGroups,
SVCDurationSeconds,
AdminPolicyName,
// Add new supported condition keys.
}, JWTKeys...)

Expand Down
2 changes: 1 addition & 1 deletion policy/condition/value.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (

func getValuesByKey(m map[string][]string, key Key) []string {
name := key.Name()
if values, found := m[name]; found {
if values, found := m[name]; found || key.name.IsAdmin() {
return values
}
return m[http.CanonicalHeaderKey(name)]
Expand Down
Loading