diff --git a/policy/action-constraint.go b/policy/action-constraint.go
index 4fae749..3461383 100644
--- a/policy/action-constraint.go
+++ b/policy/action-constraint.go
@@ -18,9 +18,10 @@
package policy
// ActionType constrains the set of policy action types. It lets generic helpers
-// accept any typed action — S3 (Action), admin, STS, KMS, Tables, Vectors, or
-// Memory — without forcing callers to convert to Action at each call site. A
-// bare string is intentionally excluded so only real policy action types match.
+// accept any typed action — S3 (Action), admin, STS, KMS, Tables, Vectors,
+// Memory, or Files — without forcing callers to convert to Action at each call
+// site. A bare string is intentionally excluded so only real policy action types
+// match.
type ActionType interface {
- Action | AdminAction | STSAction | KMSAction | TableAction | VectorsAction | MemoryAction
+ Action | AdminAction | STSAction | KMSAction | TableAction | VectorsAction | MemoryAction | FilesAction
}
diff --git a/policy/actionset.go b/policy/actionset.go
index fb9da2c..2eb2d0b 100644
--- a/policy/actionset.go
+++ b/policy/actionset.go
@@ -253,6 +253,19 @@ func (actionSet ActionSet) ToMemorySlice() []MemoryAction {
return actions
}
+// ToFilesSlice - returns slice of Files actions from the action set.
+func (actionSet ActionSet) ToFilesSlice() []FilesAction {
+ if len(actionSet) == 0 {
+ return nil
+ }
+ actions := make([]FilesAction, 0, len(actionSet))
+ for action := range actionSet {
+ actions = append(actions, FilesAction(action))
+ }
+
+ return actions
+}
+
// UnmarshalJSON - decodes JSON data to ActionSet.
func (actionSet *ActionSet) UnmarshalJSON(data []byte) error {
var sset set.StringSet
@@ -328,6 +341,16 @@ func (actionSet ActionSet) ValidateMemory() error {
return nil
}
+// ValidateFiles checks if all actions are valid Files actions
+func (actionSet ActionSet) ValidateFiles() error {
+ for _, action := range actionSet.ToFilesSlice() {
+ if !action.IsValid() {
+ return Errorf("unsupported files action '%v'", action)
+ }
+ }
+ return nil
+}
+
// Validate checks if all actions are valid
func (actionSet ActionSet) Validate() error {
for _, action := range actionSet.ToSlice() {
diff --git a/policy/files-action.go b/policy/files-action.go
new file mode 100644
index 0000000..42aee8b
--- /dev/null
+++ b/policy/files-action.go
@@ -0,0 +1,90 @@
+// 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 (
+ "github.com/minio/pkg/v3/policy/condition"
+)
+
+// FilesAction - AIStor Files management policy action. AIStor Files is an
+// NFSv4.1 gateway; these actions govern its management API (the exports it
+// serves), not the NFS data path. They are granted independently of AIStor
+// admin, so they live in their own namespace rather than under admin:.
+type FilesAction string
+
+const (
+ // FilesCreateExportAction - add an export.
+ FilesCreateExportAction FilesAction = "s3files:CreateExport"
+
+ // FilesDeleteExportAction - remove an export.
+ FilesDeleteExportAction FilesAction = "s3files:DeleteExport"
+
+ // FilesUpdateExportAction - change an existing export's settings, such as
+ // its quota.
+ FilesUpdateExportAction FilesAction = "s3files:UpdateExport"
+
+ // FilesSetExportAccessAction - set or clear an export's access rules.
+ // Separate from FilesUpdateExportAction because the access rules decide
+ // which clients may mount the export, so a grant to resize an export must
+ // not also be a grant to open it to more hosts.
+ FilesSetExportAccessAction FilesAction = "s3files:SetExportAccess"
+
+ // FilesGetExportStatusAction - list exports and read an export's
+ // configuration and status.
+ FilesGetExportStatusAction FilesAction = "s3files:GetExportStatus"
+
+ // FilesGetExportStatsAction - read per-export and fleet statistics.
+ FilesGetExportStatsAction FilesAction = "s3files:GetExportStats"
+
+ // AllFilesActions - all AIStor Files management actions.
+ AllFilesActions FilesAction = "s3files:*"
+)
+
+// SupportedFilesActions - list of all supported AIStor Files management actions.
+var SupportedFilesActions = map[FilesAction]struct{}{
+ FilesCreateExportAction: {},
+ FilesDeleteExportAction: {},
+ FilesUpdateExportAction: {},
+ FilesSetExportAccessAction: {},
+ FilesGetExportStatusAction: {},
+ FilesGetExportStatsAction: {},
+ AllFilesActions: {},
+}
+
+// IsValid - checks if action is valid or not.
+func (action FilesAction) IsValid() bool {
+ _, ok := SupportedFilesActions[action]
+ return ok
+}
+
+func createFilesActionConditionKeyMap() map[Action]condition.KeySet {
+ commonKeys := []condition.Key{}
+ for _, keyName := range condition.CommonKeys {
+ commonKeys = append(commonKeys, keyName.ToKey())
+ }
+
+ filesActionConditionKeyMap := map[Action]condition.KeySet{}
+ for act := range SupportedFilesActions {
+ filesActionConditionKeyMap[Action(act)] = condition.NewKeySet(commonKeys...)
+ }
+
+ return filesActionConditionKeyMap
+}
+
+// FilesActionConditionKeyMap - holds mapping of Files actions to condition keys.
+var FilesActionConditionKeyMap = createFilesActionConditionKeyMap()
diff --git a/policy/files-action_test.go b/policy/files-action_test.go
new file mode 100644
index 0000000..3dc4312
--- /dev/null
+++ b/policy/files-action_test.go
@@ -0,0 +1,260 @@
+// 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"
+)
+
+func TestFilesActionIsValid(t *testing.T) {
+ testCases := []struct {
+ action FilesAction
+ expectedResult bool
+ }{
+ {FilesCreateExportAction, true},
+ {FilesDeleteExportAction, true},
+ {FilesUpdateExportAction, true},
+ {FilesSetExportAccessAction, true},
+ {FilesGetExportStatusAction, true},
+ {FilesGetExportStatsAction, true},
+ {AllFilesActions, true},
+ {FilesAction("s3files:FooBar"), false},
+ {FilesAction("s3files:Get*"), false},
+ {FilesAction("s3:GetObject"), false},
+ {FilesAction("admin:ServerInfo"), false},
+ }
+
+ for i, testCase := range testCases {
+ if result := testCase.action.IsValid(); result != testCase.expectedResult {
+ t.Fatalf("case %v: action %v: expected: %v, got: %v", i+1, testCase.action, testCase.expectedResult, result)
+ }
+ }
+}
+
+func TestFilesActionConditionKeys(t *testing.T) {
+ for action := range SupportedFilesActions {
+ if _, ok := FilesActionConditionKeyMap[Action(action)]; !ok {
+ t.Fatalf("action %v: no condition key set registered", action)
+ }
+ }
+}
+
+func TestFilesStatementValidate(t *testing.T) {
+ testCases := []struct {
+ name string
+ doc string
+ wantErr string
+ }{
+ {
+ name: "one action",
+ doc: `{"Effect":"Allow","Action":["s3files:GetExportStatus"]}`,
+ },
+ {
+ name: "every action",
+ doc: `{"Effect":"Allow","Action":["s3files:CreateExport","s3files:DeleteExport",
+ "s3files:UpdateExport","s3files:SetExportAccess","s3files:GetExportStatus",
+ "s3files:GetExportStats"]}`,
+ },
+ {
+ name: "wildcard",
+ doc: `{"Effect":"Allow","Action":["s3files:*"]}`,
+ },
+ {
+ name: "deny",
+ doc: `{"Effect":"Deny","Action":["s3files:DeleteExport"]}`,
+ },
+ {
+ name: "common condition key",
+ doc: `{"Effect":"Allow","Action":["s3files:GetExportStatus"],
+ "Condition":{"IpAddress":{"aws:SourceIp":["10.0.0.0/8"]}}}`,
+ },
+ {
+ // A name outside the namespace is not a Files action, so it falls
+ // through to S3 validation, which refuses it.
+ name: "unknown action",
+ doc: `{"Effect":"Allow","Action":["s3files:FooBar"]}`,
+ wantErr: "Resource must not be empty",
+ },
+ {
+ name: "unknown action with a resource",
+ doc: `{"Effect":"Allow","Action":["s3files:FooBar"],"Resource":["arn:aws:s3:::*"]}`,
+ wantErr: "unsupported action",
+ },
+ {
+ name: "mixed with s3",
+ doc: `{"Effect":"Allow","Action":["s3files:GetExportStatus","s3:GetObject"],"Resource":["arn:aws:s3:::*"]}`,
+ wantErr: "mixing action types",
+ },
+ {
+ name: "mixed with admin",
+ doc: `{"Effect":"Allow","Action":["s3files:GetExportStatus","admin:ServerInfo"]}`,
+ wantErr: "mixing action types",
+ },
+ {
+ name: "resource",
+ doc: `{"Effect":"Allow","Action":["s3files:DeleteExport"],"Resource":["arn:aws:s3:::carol"]}`,
+ wantErr: "do not take a Resource",
+ },
+ {
+ name: "wildcard resource",
+ doc: `{"Effect":"Allow","Action":["s3files:*"],"Resource":["*"]}`,
+ wantErr: "do not take a Resource",
+ },
+ {
+ name: "not resource",
+ doc: `{"Effect":"Deny","Action":["s3files:DeleteExport"],"NotResource":["arn:aws:s3:::carol"]}`,
+ wantErr: "do not take a Resource",
+ },
+ {
+ name: "unsupported condition key",
+ doc: `{"Effect":"Allow","Action":["s3files:GetExportStatus"],
+ "Condition":{"StringEquals":{"s3:prefix":["x"]}}}`,
+ wantErr: "unsupported condition keys",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ doc := `{"Version":"2012-10-17","Statement":[` + tc.doc + `]}`
+ for _, parse := range []struct {
+ name string
+ fn func(string) error
+ }{
+ {"ParseConfig", func(d string) error { _, err := ParseConfig(strings.NewReader(d)); return err }},
+ {"ParseConfigStrict", func(d string) error { _, err := ParseConfigStrict(strings.NewReader(d)); return err }},
+ } {
+ err := parse.fn(doc)
+ switch {
+ case tc.wantErr == "" && err != nil:
+ t.Errorf("%s: unexpected error: %v", parse.name, err)
+ case tc.wantErr != "" && err == nil:
+ t.Errorf("%s: expected an error containing %q, got none", parse.name, tc.wantErr)
+ case tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr):
+ t.Errorf("%s: expected an error containing %q, got %v", parse.name, tc.wantErr, err)
+ }
+ }
+ })
+ }
+}
+
+// filesArgs is how a server asks about a Files management request: the export
+// is named by the request, so the args carry no bucket or object.
+func filesArgs(action FilesAction) Args {
+ return Args{AccountName: "files-operator", Action: Action(action)}
+}
+
+func mustParse(t *testing.T, doc string) *Policy {
+ t.Helper()
+ p, err := ParseConfig(strings.NewReader(doc))
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ return p
+}
+
+func TestFilesPolicyIsAllowed(t *testing.T) {
+ allFiles := []FilesAction{
+ FilesCreateExportAction,
+ FilesDeleteExportAction,
+ FilesUpdateExportAction,
+ FilesSetExportAccessAction,
+ FilesGetExportStatusAction,
+ FilesGetExportStatsAction,
+ }
+
+ t.Run("read only grants list and inspect, nothing else", func(t *testing.T) {
+ p := mustParse(t, `{"Version":"2012-10-17","Statement":[
+ {"Effect":"Allow","Action":["s3files:GetExportStatus"]}]}`)
+ for _, action := range allFiles {
+ want := action == FilesGetExportStatusAction
+ if got := p.IsAllowed(filesArgs(action)); got != want {
+ t.Errorf("%v: allowed=%v, want %v", action, got, want)
+ }
+ }
+ })
+
+ t.Run("wildcard grants every files action and only those", func(t *testing.T) {
+ p := mustParse(t, `{"Version":"2012-10-17","Statement":[
+ {"Effect":"Allow","Action":["s3files:*"]}]}`)
+ for _, action := range allFiles {
+ if !p.IsAllowed(filesArgs(action)) {
+ t.Errorf("%v: denied by s3files:*", action)
+ }
+ }
+ for _, other := range []Action{Action(ServerInfoAdminAction), GetObjectAction, Action(MemoryListCortexesAction)} {
+ if p.IsAllowed(Args{Action: other, BucketName: "b", ObjectName: "o"}) {
+ t.Errorf("%v: allowed by s3files:*", other)
+ }
+ }
+ })
+
+ t.Run("s3 and admin wildcards grant no files action", func(t *testing.T) {
+ p := mustParse(t, `{"Version":"2012-10-17","Statement":[
+ {"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]},
+ {"Effect":"Allow","Action":["admin:*"]}]}`)
+ for _, action := range allFiles {
+ if p.IsAllowed(filesArgs(action)) {
+ t.Errorf("%v: allowed by s3:* + admin:*", action)
+ }
+ }
+ })
+
+ t.Run("deny wins", func(t *testing.T) {
+ p := mustParse(t, `{"Version":"2012-10-17","Statement":[
+ {"Effect":"Allow","Action":["s3files:*"]},
+ {"Effect":"Deny","Action":["s3files:DeleteExport"]}]}`)
+ if p.IsAllowed(filesArgs(FilesDeleteExportAction)) {
+ t.Error("s3files:DeleteExport allowed despite an explicit Deny")
+ }
+ if !p.IsAllowed(filesArgs(FilesCreateExportAction)) {
+ t.Error("s3files:CreateExport denied; the Deny names only DeleteExport")
+ }
+ })
+
+ t.Run("condition applies", func(t *testing.T) {
+ p := mustParse(t, `{"Version":"2012-10-17","Statement":[
+ {"Effect":"Allow","Action":["s3files:GetExportStatus"],
+ "Condition":{"IpAddress":{"aws:SourceIp":["10.0.0.0/8"]}}}]}`)
+ inside := filesArgs(FilesGetExportStatusAction)
+ inside.ConditionValues = map[string][]string{"SourceIp": {"10.1.2.3"}}
+ if !p.IsAllowed(inside) {
+ t.Error("denied from inside the allowed network")
+ }
+ outside := filesArgs(FilesGetExportStatusAction)
+ outside.ConditionValues = map[string][]string{"SourceIp": {"192.168.1.1"}}
+ if p.IsAllowed(outside) {
+ t.Error("allowed from outside the allowed network")
+ }
+ })
+}
+
+func TestFilesActionNotGrantedByDefaultPolicies(t *testing.T) {
+ // No canned policy grants the Files namespace yet, consoleAdmin included:
+ // it is granted on its own, as the issue that introduced it asks.
+ for _, dp := range DefaultPolicies {
+ for action := range SupportedFilesActions {
+ if action == AllFilesActions {
+ continue
+ }
+ if dp.Definition.IsAllowed(filesArgs(action)) {
+ t.Errorf("default policy %q allows %v", dp.Name, action)
+ }
+ }
+ }
+}
diff --git a/policy/statement.go b/policy/statement.go
index 664955c..e4b055e 100644
--- a/policy/statement.go
+++ b/policy/statement.go
@@ -166,7 +166,7 @@ func (statement Statement) validateActionTypes() error {
if len(actions) == 0 {
actions = statement.NotActions
}
- var hasS3, hasAdmin, hasSTS, hasKMS, hasTable, hasVectors, hasMemory bool
+ var hasS3, hasAdmin, hasSTS, hasKMS, hasTable, hasVectors, hasMemory, hasFiles bool
for action := range actions {
switch {
case AdminAction(action).IsValid():
@@ -181,12 +181,14 @@ func (statement Statement) validateActionTypes() error {
hasVectors = true
case MemoryAction(action).IsValid():
hasMemory = true
+ case FilesAction(action).IsValid():
+ hasFiles = true
default:
hasS3 = true
}
}
count := 0
- for _, b := range []bool{hasS3, hasAdmin, hasSTS, hasKMS, hasTable, hasVectors, hasMemory} {
+ for _, b := range []bool{hasS3, hasAdmin, hasSTS, hasKMS, hasTable, hasVectors, hasMemory, hasFiles} {
if b {
count++
}
@@ -316,6 +318,15 @@ func (statement Statement) isMemory() bool {
return false
}
+func (statement Statement) isFiles() bool {
+ for action := range statement.Actions {
+ if FilesAction(action).IsValid() {
+ return true
+ }
+ }
+ return false
+}
+
// isValid - checks whether statement is valid or not.
func (statement Statement) isValid() error {
if !statement.Effect.IsValid() {
@@ -485,6 +496,31 @@ func (statement Statement) isValid() error {
return nil
}
+ if statement.isFiles() {
+ if err := statement.Actions.ValidateFiles(); err != nil {
+ return err
+ }
+ for action := range statement.Actions {
+ keys := statement.Conditions.Keys()
+ keyDiff := keys.Difference(FilesActionConditionKeyMap[action])
+ if !keyDiff.IsEmpty() {
+ return Errorf("unsupported condition keys '%v' used for action '%v'", keyDiff, action)
+ }
+ }
+
+ // Files actions name no resource yet: an export is addressed by the
+ // request, not by an ARN in the policy. Refuse a Resource rather than
+ // ignore it, because an ignored Resource on an Allow would read as a
+ // grant scoped to one export while granting every export. Refusing now
+ // also leaves export-scoped ARNs free to be added later without changing
+ // what an existing policy means.
+ if len(statement.Resources) > 0 || len(statement.NotResources) > 0 {
+ return Errorf("s3files actions do not take a Resource or NotResource")
+ }
+
+ return nil
+ }
+
if !statement.SID.IsValid() {
return Errorf("invalid SID %v", statement.SID)
}