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