From d802f2d423c6a4966ebb76688a5376b8b90de16c Mon Sep 17 00:00:00 2001 From: Allan Roger Reid Date: Tue, 21 Apr 2026 21:16:29 +0000 Subject: [PATCH 1/2] feat: add consolereadonly canned policy Adds a new built-in policy 'consolereadonly' that mirrors 'readonly' but additionally grants s3:ListBucket so users can browse bucket contents (e.g. via the console) without gaining write access. Also adds tests covering both the existing readonly shape and the new consolereadonly policy, including a regression guard that readonly does not allow s3:ListBucket. --- policy/constants.go | 22 ++++++++ policy/constants_test.go | 113 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 policy/constants_test.go diff --git a/policy/constants.go b/policy/constants.go index beb51a5..5d54906 100644 --- a/policy/constants.go +++ b/policy/constants.go @@ -70,6 +70,28 @@ var DefaultPolicies = []struct { }, }, + // ConsoleReadOnly - read only with ListBucket for console browsing. + { + Name: "consolereadonly", + Definition: Policy{ + Version: DefaultVersion, + Statements: []Statement{ + { + SID: ID(""), + Effect: Allow, + Actions: NewActionSet(GetBucketLocationAction, GetObjectAction, ListBucketAction), + Resources: NewResourceSet(NewResource("*")), + }, + { + SID: ID(""), + Effect: Deny, + Actions: NewActionSet(CreateUserAdminAction), + Resources: NewResourceSet(NewResource("*")), + }, + }, + }, + }, + // WriteOnly - provides write access. { Name: "writeonly", diff --git a/policy/constants_test.go b/policy/constants_test.go new file mode 100644 index 0000000..38a1e1d --- /dev/null +++ b/policy/constants_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 "testing" + +func findDefaultPolicy(name string) (Policy, bool) { + for _, p := range DefaultPolicies { + if p.Name == name { + return p.Definition, true + } + } + return Policy{}, false +} + +func TestDefaultPolicyReadOnly(t *testing.T) { + p, ok := findDefaultPolicy("readonly") + if !ok { + t.Fatal("readonly default policy not found") + } + if err := p.Validate(); err != nil { + t.Fatalf("readonly policy invalid: %v", err) + } + + allowed := NewActionSet(GetBucketLocationAction, GetObjectAction) + denied := NewActionSet(CreateUserAdminAction) + + var sawAllow, sawDeny bool + for _, s := range p.Statements { + switch s.Effect { + case Allow: + sawAllow = true + if !s.Actions.Equals(allowed) { + t.Errorf("readonly Allow actions = %v, want %v", s.Actions, allowed) + } + case Deny: + sawDeny = true + if !s.Actions.Equals(denied) { + t.Errorf("readonly Deny actions = %v, want %v", s.Actions, denied) + } + } + } + if !sawAllow || !sawDeny { + t.Errorf("readonly missing Allow/Deny statement: allow=%v deny=%v", sawAllow, sawDeny) + } +} + +func TestDefaultPolicyConsoleReadOnly(t *testing.T) { + p, ok := findDefaultPolicy("consolereadonly") + if !ok { + t.Fatal("consolereadonly default policy not found") + } + if err := p.Validate(); err != nil { + t.Fatalf("consolereadonly policy invalid: %v", err) + } + + allowed := NewActionSet(GetBucketLocationAction, GetObjectAction, ListBucketAction) + denied := NewActionSet(CreateUserAdminAction) + + var sawAllow, sawDeny bool + for _, s := range p.Statements { + switch s.Effect { + case Allow: + sawAllow = true + if !s.Actions.Equals(allowed) { + t.Errorf("consolereadonly Allow actions = %v, want %v", s.Actions, allowed) + } + case Deny: + sawDeny = true + if !s.Actions.Equals(denied) { + t.Errorf("consolereadonly Deny actions = %v, want %v", s.Actions, denied) + } + } + } + if !sawAllow || !sawDeny { + t.Errorf("consolereadonly missing Allow/Deny statement: allow=%v deny=%v", sawAllow, sawDeny) + } +} + +func TestDefaultPolicyConsoleReadOnlyAllowsListBucket(t *testing.T) { + p, ok := findDefaultPolicy("consolereadonly") + if !ok { + t.Fatal("consolereadonly default policy not found") + } + args := Args{ + AccountName: "testuser", + Action: ListBucketAction, + BucketName: "bucket1", + } + if !p.IsAllowed(args) { + t.Error("consolereadonly should allow s3:ListBucket") + } + + ro, _ := findDefaultPolicy("readonly") + if ro.IsAllowed(args) { + t.Error("readonly should NOT allow s3:ListBucket (sanity check)") + } +} From 7e6b596fabf11cde14dc13e4bda7582b1f1f0f39 Mon Sep 17 00:00:00 2001 From: Allan Roger Reid Date: Tue, 21 Apr 2026 21:45:03 +0000 Subject: [PATCH 2/2] test: assert readonly lookup succeeds in consolereadonly regression guard Without the ok check, if the readonly default policy were ever removed or renamed, findDefaultPolicy returns a zero Policy{} whose IsAllowed always returns false, making the regression guard vacuously pass. Fail fast instead so the assertion cannot silently rot. --- policy/constants_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policy/constants_test.go b/policy/constants_test.go index 38a1e1d..3ade940 100644 --- a/policy/constants_test.go +++ b/policy/constants_test.go @@ -106,7 +106,10 @@ func TestDefaultPolicyConsoleReadOnlyAllowsListBucket(t *testing.T) { t.Error("consolereadonly should allow s3:ListBucket") } - ro, _ := findDefaultPolicy("readonly") + ro, ok := findDefaultPolicy("readonly") + if !ok { + t.Fatal("readonly default policy not found") + } if ro.IsAllowed(args) { t.Error("readonly should NOT allow s3:ListBucket (sanity check)") }