diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go index 225b8dd41..07b770f6a 100644 --- a/weed/admin/dash/file_browser_data.go +++ b/weed/admin/dash/file_browser_data.go @@ -53,8 +53,11 @@ type FileBrowserData struct { CurrentLastFileName string `json:"current_last_file_name"` // Cursor from current request (for page size changes) } -// GetFileBrowser retrieves file browser data for a given path with cursor-based pagination -func (s *AdminServer) GetFileBrowser(dir string, lastFileName string, pageSize int) (*FileBrowserData, error) { +// GetFileBrowser retrieves file browser data for a given path with cursor-based +// pagination. A non-empty prefix limits the listing to entries whose name starts +// with it, so callers that only want part of a large directory don't have to page +// through all of it. +func (s *AdminServer) GetFileBrowser(dir string, prefix string, lastFileName string, pageSize int) (*FileBrowserData, error) { if dir == "" { dir = "/" } @@ -76,7 +79,7 @@ func (s *AdminServer) GetFileBrowser(dir string, lastFileName string, pageSize i // Fetch entries starting from the cursor (lastFileName) stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ Directory: dir, - Prefix: "", + Prefix: prefix, Limit: uint32(fetchLimit), StartFromFileName: lastFileName, InclusiveStartFrom: false, // Don't include the cursor file itself diff --git a/weed/admin/dash/principal_suggestions.go b/weed/admin/dash/principal_suggestions.go new file mode 100644 index 000000000..a3a225682 --- /dev/null +++ b/weed/admin/dash/principal_suggestions.go @@ -0,0 +1,57 @@ +package dash + +import ( + "context" + + weediam "github.com/seaweedfs/seaweedfs/weed/iam" + "github.com/seaweedfs/seaweedfs/weed/iam/integration" + + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +// principalRoleArn builds the ARN SeaweedFS assigns a role by default (when +// its RoleDefinition.RoleArn isn't explicitly set) - see +// weed/iam/integration/iam_manager.go's CreateRole. ListRoles only returns +// role names, so this reconstructs the well-known default rather than +// fetching every role's stored definition just to populate a suggestion list. +func principalRoleArn(roleName string) string { + return "arn:aws:iam::role/" + roleName +} + +// GetPrincipalSuggestions returns candidate ARNs for the policy editor's +// Principal/NotPrincipal autocomplete: one per S3 user, plus one per IAM +// role. Service accounts are deliberately not listed separately - a service +// account is just an additional credential for its parent user, so its ARN +// is identical to the one already suggested for that user. +// +// Role listing is best-effort: if the filer or role store is unavailable, +// the error is logged and suggestions fall back to users only, since an +// incomplete autocomplete list is far less disruptive than blocking policy +// editing over a suggestions-only feature. +func (s *AdminServer) GetPrincipalSuggestions(ctx context.Context) ([]string, error) { + var suggestions []string + + users, err := s.GetObjectStoreUsers(ctx) + if err != nil { + return nil, err + } + for _, u := range users { + suggestions = append(suggestions, weediam.UserArn(u.Username)) + } + + roleStore, err := integration.NewFilerRoleStore(nil, func() string { return s.GetFilerAddress() }) + if err != nil { + glog.Warningf("GetPrincipalSuggestions: failed to create role store: %v", err) + return suggestions, nil + } + roleNames, err := roleStore.ListRoles(ctx, s.GetFilerAddress()) + if err != nil { + glog.Warningf("GetPrincipalSuggestions: failed to list roles: %v", err) + return suggestions, nil + } + for _, roleName := range roleNames { + suggestions = append(suggestions, principalRoleArn(roleName)) + } + + return suggestions, nil +} diff --git a/weed/admin/dash/principal_suggestions_test.go b/weed/admin/dash/principal_suggestions_test.go new file mode 100644 index 000000000..8c3927ed1 --- /dev/null +++ b/weed/admin/dash/principal_suggestions_test.go @@ -0,0 +1,11 @@ +package dash + +import "testing" + +func TestPrincipalRoleArn(t *testing.T) { + got := principalRoleArn("S3ReadOnlyRole") + want := "arn:aws:iam::role/S3ReadOnlyRole" + if got != want { + t.Fatalf("principalRoleArn() = %q, want %q", got, want) + } +} diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 097642407..767790764 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -224,6 +224,10 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { policyApi.Handle("/{name}", wrapWrite(h.policyHandlers.DeletePolicy)).Methods(http.MethodDelete) policyApi.HandleFunc("/validate", h.policyHandlers.ValidatePolicy).Methods(http.MethodPost) + // Registered at the API root, not under policyApi: policyApi's "/{name}" + // GET route would shadow any single-segment GET route registered after it. + api.HandleFunc("/principals", h.policyHandlers.GetPrincipalSuggestions).Methods(http.MethodGet) + s3TablesApi := api.PathPrefix("/s3tables").Subrouter() s3TablesApi.HandleFunc("/buckets", h.adminServer.ListS3TablesBucketsAPI).Methods(http.MethodGet) s3TablesApi.Handle("/buckets", wrapWrite(h.adminServer.CreateS3TablesBucket)).Methods(http.MethodPost) @@ -253,6 +257,7 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { filesApi.HandleFunc("/view", h.fileBrowserHandlers.ViewFile).Methods(http.MethodGet) filesApi.HandleFunc("/properties", h.fileBrowserHandlers.GetFileProperties).Methods(http.MethodGet) filesApi.HandleFunc("/metadata", h.fileBrowserHandlers.ExportMetadata).Methods(http.MethodGet) + filesApi.HandleFunc("/list-folders", h.fileBrowserHandlers.ListFolders).Methods(http.MethodGet) volumeApi := api.PathPrefix("/volumes").Subrouter() volumeApi.HandleFunc("/export", h.clusterHandlers.ExportClusterVolumes).Methods(http.MethodGet) diff --git a/weed/admin/handlers/admin_handlers_routes_test.go b/weed/admin/handlers/admin_handlers_routes_test.go index 521a4d318..9cbd35528 100644 --- a/weed/admin/handlers/admin_handlers_routes_test.go +++ b/weed/admin/handlers/admin_handlers_routes_test.go @@ -62,6 +62,48 @@ func TestSetupRoutes_RegistersBucketLifecycleAPI_WithAuth(t *testing.T) { assertHasRoute(t, router, http.MethodDelete, "/api/s3/buckets/example/lifecycle") } +func TestSetupRoutes_RegistersPolicyAPI_NoAuth(t *testing.T) { + router := mux.NewRouter() + + newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) + + assertHasRoute(t, router, http.MethodGet, "/api/object-store/policies") + assertHasRoute(t, router, http.MethodPost, "/api/object-store/policies") + assertHasRoute(t, router, http.MethodGet, "/api/object-store/policies/example") + assertHasRoute(t, router, http.MethodPut, "/api/object-store/policies/example") + assertHasRoute(t, router, http.MethodDelete, "/api/object-store/policies/example") + assertHasRoute(t, router, http.MethodPost, "/api/object-store/policies/validate") +} + +func TestSetupRoutes_RegistersPolicyAPI_WithAuth(t *testing.T) { + router := mux.NewRouter() + + newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true) + + assertHasRoute(t, router, http.MethodGet, "/api/object-store/policies") + assertHasRoute(t, router, http.MethodPost, "/api/object-store/policies") + assertHasRoute(t, router, http.MethodGet, "/api/object-store/policies/example") + assertHasRoute(t, router, http.MethodPut, "/api/object-store/policies/example") + assertHasRoute(t, router, http.MethodDelete, "/api/object-store/policies/example") + assertHasRoute(t, router, http.MethodPost, "/api/object-store/policies/validate") +} + +func TestSetupRoutes_RegistersPrincipalsAPI_NoAuth(t *testing.T) { + router := mux.NewRouter() + + newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) + + assertHasRoute(t, router, http.MethodGet, "/api/principals") +} + +func TestSetupRoutes_RegistersFilesListFoldersAPI_NoAuth(t *testing.T) { + router := mux.NewRouter() + + newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) + + assertHasRoute(t, router, http.MethodGet, "/api/files/list-folders") +} + func TestSetupRoutes_RegistersPluginPages_NoAuth(t *testing.T) { router := mux.NewRouter() diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index e68b5db6f..eb15c0355 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -66,7 +66,7 @@ func (h *FileBrowserHandlers) ShowFileBrowser(w http.ResponseWriter, r *http.Req } // Get file browser data with cursor-based pagination - browserData, err := h.adminServer.GetFileBrowser(path, lastFileName, pageSize) + browserData, err := h.adminServer.GetFileBrowser(path, "", lastFileName, pageSize) if err != nil { writeJSONError(w, http.StatusInternalServerError, "Failed to get file browser data: "+err.Error()) return @@ -778,3 +778,63 @@ func min(a, b int64) int64 { } return b } + +// maxListFoldersEntries caps how many subfolder names ListFolders will +// collect for a single request, so a directory with an unusually large +// number of children can't turn one autocomplete keystroke into an +// unbounded, slow full-directory walk. +const maxListFoldersEntries = 2000 + +// maxListFoldersScanned caps how many entries ListFolders will page through +// looking for those subfolders. The folder cap alone doesn't bound the work: +// a bucket holding nothing but flat object keys has no subfolders to count, +// so the walk runs to the end of the directory - a million keys is a million +// entries read, 200 per round trip, behind a single keystroke. +const maxListFoldersScanned = 10000 + +// ListFolders returns, as JSON, the names of the subdirectories directly +// under the given path. It exists to back progressive autocomplete (e.g. the +// policy editor's Resource ARN field building up "bucket/folder/subfolder" +// one path segment at a time) rather than to be a general directory listing +// API, so it's restricted to paths under /buckets. The optional prefix is the +// segment the caller is still typing, and narrows the listing to it. +func (h *FileBrowserHandlers) ListFolders(w http.ResponseWriter, r *http.Request) { + dirPath := defaultQuery(r.URL.Query().Get("path"), "/buckets") + // Clean before the scope check: CleanWindowsPath only rewrites backslashes, + // so "/buckets/../etc" would otherwise satisfy the prefix test below. + dirPath = path.Clean(util.CleanWindowsPath(dirPath)) + + if dirPath != "/buckets" && !strings.HasPrefix(dirPath, "/buckets/") { + writeJSONError(w, http.StatusBadRequest, "path must be under /buckets") + return + } + + prefix := r.URL.Query().Get("prefix") + + folders := []string{} + lastFileName := "" + scanned := 0 + for { + browserData, err := h.adminServer.GetFileBrowser(dirPath, prefix, lastFileName, 200) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to list directory: "+err.Error()) + return + } + scanned += len(browserData.Entries) + for _, entry := range browserData.Entries { + if entry.IsDirectory { + folders = append(folders, entry.Name) + if len(folders) >= maxListFoldersEntries { + break + } + } + } + if len(browserData.Entries) == 0 || !browserData.HasNextPage || + len(folders) >= maxListFoldersEntries || scanned >= maxListFoldersScanned { + break + } + lastFileName = browserData.Entries[len(browserData.Entries)-1].Name + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"folders": folders}) +} diff --git a/weed/admin/handlers/file_browser_handlers_test.go b/weed/admin/handlers/file_browser_handlers_test.go index 3baf9b374..65d05dad9 100644 --- a/weed/admin/handlers/file_browser_handlers_test.go +++ b/weed/admin/handlers/file_browser_handlers_test.go @@ -1,7 +1,11 @@ package handlers import ( + "net/http" + "net/http/httptest" "testing" + + "github.com/seaweedfs/seaweedfs/weed/admin/dash" ) func TestValidateAndCleanFilePath_AllowsControlChars(t *testing.T) { @@ -42,3 +46,20 @@ func TestValidateAndCleanFilePath_RejectsEmpty(t *testing.T) { } } +func TestListFolders_RejectsPathOutsideBuckets(t *testing.T) { + h := &FileBrowserHandlers{adminServer: &dash.AdminServer{}} + + cases := []string{"/etc/seaweedfs", "/", "/notbuckets/foo", "/buckets-not-really/foo", + "/buckets/../etc", "/buckets/..", "/buckets/foo/../../etc", `/buckets\..\etc`} + for _, path := range cases { + req := httptest.NewRequest(http.MethodGet, "/api/files/list-folders?path="+path, nil) + w := httptest.NewRecorder() + + h.ListFolders(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("path %q: expected 400, got %d (body: %s)", path, w.Code, w.Body.String()) + } + } +} + diff --git a/weed/admin/handlers/policy_handlers.go b/weed/admin/handlers/policy_handlers.go index 81868213f..d9a6d786e 100644 --- a/weed/admin/handlers/policy_handlers.go +++ b/weed/admin/handlers/policy_handlers.go @@ -51,6 +51,17 @@ func (h *PolicyHandlers) GetPolicies(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"policies": policies}) } +// GetPrincipalSuggestions returns candidate ARNs (existing users and IAM +// roles) for the policy editor's Principal/NotPrincipal autocomplete. +func (h *PolicyHandlers) GetPrincipalSuggestions(w http.ResponseWriter, r *http.Request) { + suggestions, err := h.adminServer.GetPrincipalSuggestions(r.Context()) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to get principal suggestions: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"principals": suggestions}) +} + // CreatePolicy handles policy creation func (h *PolicyHandlers) CreatePolicy(w http.ResponseWriter, r *http.Request) { var req dash.CreatePolicyRequest @@ -221,8 +232,18 @@ func (h *PolicyHandlers) ValidatePolicy(w http.ResponseWriter, r *http.Request) return } - if len(statement.Resource.Strings()) == 0 { - writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Resource is required", i+1)) + if len(statement.Resource.Strings()) == 0 && len(statement.NotResource.Strings()) == 0 { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Resource or NotResource is required", i+1)) + return + } + + if statement.Resource != nil && statement.NotResource != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: cannot specify both Resource and NotResource", i+1)) + return + } + + if statement.Principal != nil && statement.NotPrincipal != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: cannot specify both Principal and NotPrincipal", i+1)) return } } diff --git a/weed/admin/handlers/policy_handlers_test.go b/weed/admin/handlers/policy_handlers_test.go new file mode 100644 index 000000000..21484148b --- /dev/null +++ b/weed/admin/handlers/policy_handlers_test.go @@ -0,0 +1,161 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/admin/dash" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" +) + +func newValidatePolicyRequest(t *testing.T, document map[string]interface{}) *http.Request { + t.Helper() + body, err := json.Marshal(map[string]interface{}{"document": document}) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/object-store/policies/validate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func TestValidatePolicy(t *testing.T) { + handlers := &PolicyHandlers{adminServer: &dash.AdminServer{}} + + tests := []struct { + name string + document map[string]interface{} + wantStatus int + }{ + { + name: "valid document with Resource", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-bucket/*"}, + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "valid document with NotResource only", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Action": "s3:GetObject", "NotResource": "arn:aws:s3:::secret-bucket/*"}, + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "missing version", + document: map[string]interface{}{ + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-bucket/*"}, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "no statements", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{}, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid effect", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Maybe", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-bucket/*"}, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing action", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Resource": "arn:aws:s3:::my-bucket/*"}, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "neither Resource nor NotResource", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Action": "s3:GetObject"}, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "both Resource and NotResource", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-bucket/*", "NotResource": "arn:aws:s3:::secret-bucket/*"}, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "empty Resource alongside non-empty NotResource is still a conflict", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Action": "s3:GetObject", "Resource": []string{}, "NotResource": "arn:aws:s3:::secret-bucket/*"}, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "both Principal and NotPrincipal", + document: map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-bucket/*", "Principal": "*", "NotPrincipal": "arn:aws:iam::123456789012:user/bob"}, + }, + }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := newValidatePolicyRequest(t, tt.document) + w := httptest.NewRecorder() + + handlers.ValidatePolicy(w, req) + + if w.Code != tt.wantStatus { + t.Fatalf("expected status %d, got %d (body: %s)", tt.wantStatus, w.Code, w.Body.String()) + } + }) + } +} + +// Sanity check that policy_engine.PolicyStatement (the type ValidatePolicy +// actually decodes into) round-trips NotResource-only statements the way the +// test above assumes. +func TestValidatePolicy_NotResourceOnlyDecodes(t *testing.T) { + raw := []byte(`{"Effect":"Allow","Action":"s3:GetObject","NotResource":"arn:aws:s3:::secret/*"}`) + var stmt policy_engine.PolicyStatement + if err := json.Unmarshal(raw, &stmt); err != nil { + t.Fatalf("failed to unmarshal statement: %v", err) + } + if len(stmt.Resource.Strings()) != 0 { + t.Fatalf("expected no Resource, got %v", stmt.Resource.Strings()) + } + if len(stmt.NotResource.Strings()) != 1 { + t.Fatalf("expected exactly one NotResource entry, got %v", stmt.NotResource.Strings()) + } +} diff --git a/weed/admin/view/app/policies.templ b/weed/admin/view/app/policies.templ index 6d7ade5da..ac529054f 100644 --- a/weed/admin/view/app/policies.templ +++ b/weed/admin/view/app/policies.templ @@ -172,9 +172,52 @@ templ Policies(data dash.PoliciesData) { + + + + + for _, action := range PolicyActionSuggestions { + + } + + + + + + + + ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, action := range PolicyActionSuggestions { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Create IAM Policy
Enter a unique name for this policy (alphanumeric and underscores only)
Enter the policy document in AWS IAM JSON format
View IAM Policy
Loading...

Loading policy...

Edit IAM Policy
Policy name cannot be changed
Edit the policy document in AWS IAM JSON format
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/policy_action_suggestions.go b/weed/admin/view/app/policy_action_suggestions.go new file mode 100644 index 000000000..608bc8eaf --- /dev/null +++ b/weed/admin/view/app/policy_action_suggestions.go @@ -0,0 +1,107 @@ +package app + +import "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + +// PolicyActionSuggestions feeds the used by the structured policy +// editor's Action inputs. This is an input-assistance aid, NOT a validation +// list: the editor accepts any action string typed by hand, since IAM policy +// actions are not restricted to this set (custom/future actions, wildcards +// like "s3:Get*", etc.). +var PolicyActionSuggestions = buildPolicyActionSuggestions() + +func buildPolicyActionSuggestions() []string { + return []string{ + // s3: actions, sourced from the constants used elsewhere for policy + // evaluation (weed/s3api/s3_constants/s3_action_strings.go) so the + // suggestion list can't drift from the strings the engine actually + // understands. + s3_constants.S3_ACTION_ALL, + s3_constants.S3_ACTION_GET_OBJECT, + s3_constants.S3_ACTION_PUT_OBJECT, + s3_constants.S3_ACTION_DELETE_OBJECT, + s3_constants.S3_ACTION_DELETE_OBJECT_VERSION, + s3_constants.S3_ACTION_GET_OBJECT_VERSION, + s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES, + s3_constants.S3_ACTION_GET_OBJECT_ACL, + s3_constants.S3_ACTION_PUT_OBJECT_ACL, + s3_constants.S3_ACTION_GET_OBJECT_TAGGING, + s3_constants.S3_ACTION_PUT_OBJECT_TAGGING, + s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING, + s3_constants.S3_ACTION_GET_OBJECT_RETENTION, + s3_constants.S3_ACTION_PUT_OBJECT_RETENTION, + s3_constants.S3_ACTION_GET_OBJECT_LEGAL_HOLD, + s3_constants.S3_ACTION_PUT_OBJECT_LEGAL_HOLD, + s3_constants.S3_ACTION_BYPASS_GOVERNANCE, + s3_constants.S3_ACTION_CREATE_MULTIPART, + s3_constants.S3_ACTION_UPLOAD_PART, + s3_constants.S3_ACTION_COMPLETE_MULTIPART, + s3_constants.S3_ACTION_ABORT_MULTIPART, + s3_constants.S3_ACTION_UPLOAD_PART_COPY, + s3_constants.S3_ACTION_LIST_PARTS, + s3_constants.S3_ACTION_LIST_MULTIPART_UPLOADS, + s3_constants.S3_ACTION_CREATE_BUCKET, + s3_constants.S3_ACTION_DELETE_BUCKET, + s3_constants.S3_ACTION_LIST_BUCKET, + s3_constants.S3_ACTION_LIST_BUCKET_VERSIONS, + s3_constants.S3_ACTION_GET_BUCKET_ACL, + s3_constants.S3_ACTION_PUT_BUCKET_ACL, + s3_constants.S3_ACTION_GET_BUCKET_POLICY, + s3_constants.S3_ACTION_PUT_BUCKET_POLICY, + s3_constants.S3_ACTION_DELETE_BUCKET_POLICY, + s3_constants.S3_ACTION_GET_BUCKET_TAGGING, + s3_constants.S3_ACTION_PUT_BUCKET_TAGGING, + s3_constants.S3_ACTION_DELETE_BUCKET_TAGGING, + s3_constants.S3_ACTION_GET_BUCKET_CORS, + s3_constants.S3_ACTION_PUT_BUCKET_CORS, + s3_constants.S3_ACTION_DELETE_BUCKET_CORS, + s3_constants.S3_ACTION_GET_BUCKET_LIFECYCLE, + s3_constants.S3_ACTION_PUT_BUCKET_LIFECYCLE, + s3_constants.S3_ACTION_GET_BUCKET_VERSIONING, + s3_constants.S3_ACTION_PUT_BUCKET_VERSIONING, + s3_constants.S3_ACTION_GET_BUCKET_LOCATION, + s3_constants.S3_ACTION_GET_BUCKET_NOTIFICATION, + s3_constants.S3_ACTION_PUT_BUCKET_NOTIFICATION, + s3_constants.S3_ACTION_GET_BUCKET_OBJECT_LOCK, + s3_constants.S3_ACTION_PUT_BUCKET_OBJECT_LOCK, + + // s3tables: actions, sourced from weed/s3api/s3_constants so this list + // can't drift from the strings the s3tables engine actually understands. + s3_constants.S3TABLES_ACTION_ALL, + s3_constants.S3TABLES_ACTION_CREATE_TABLE_BUCKET, + s3_constants.S3TABLES_ACTION_GET_TABLE_BUCKET, + s3_constants.S3TABLES_ACTION_LIST_TABLE_BUCKETS, + s3_constants.S3TABLES_ACTION_DELETE_TABLE_BUCKET, + s3_constants.S3TABLES_ACTION_PUT_TABLE_BUCKET_POLICY, + s3_constants.S3TABLES_ACTION_GET_TABLE_BUCKET_POLICY, + s3_constants.S3TABLES_ACTION_DELETE_TABLE_BUCKET_POLICY, + s3_constants.S3TABLES_ACTION_CREATE_NAMESPACE, + s3_constants.S3TABLES_ACTION_GET_NAMESPACE, + s3_constants.S3TABLES_ACTION_UPDATE_NAMESPACE, + s3_constants.S3TABLES_ACTION_LIST_NAMESPACES, + s3_constants.S3TABLES_ACTION_DELETE_NAMESPACE, + s3_constants.S3TABLES_ACTION_CREATE_TABLE, + s3_constants.S3TABLES_ACTION_REGISTER_TABLE, + s3_constants.S3TABLES_ACTION_GET_TABLE, + s3_constants.S3TABLES_ACTION_LIST_TABLES, + s3_constants.S3TABLES_ACTION_UPDATE_TABLE, + s3_constants.S3TABLES_ACTION_DELETE_TABLE, + s3_constants.S3TABLES_ACTION_RENAME_TABLE, + s3_constants.S3TABLES_ACTION_CREATE_VIEW, + s3_constants.S3TABLES_ACTION_GET_VIEW, + s3_constants.S3TABLES_ACTION_LIST_VIEWS, + s3_constants.S3TABLES_ACTION_UPDATE_VIEW, + s3_constants.S3TABLES_ACTION_DELETE_VIEW, + s3_constants.S3TABLES_ACTION_RENAME_VIEW, + s3_constants.S3TABLES_ACTION_PUT_TABLE_POLICY, + s3_constants.S3TABLES_ACTION_GET_TABLE_POLICY, + s3_constants.S3TABLES_ACTION_DELETE_TABLE_POLICY, + s3_constants.S3TABLES_ACTION_PUT_TABLE_BUCKET_MAINTENANCE_CONFIGURATION, + s3_constants.S3TABLES_ACTION_GET_TABLE_BUCKET_MAINTENANCE_CONFIGURATION, + s3_constants.S3TABLES_ACTION_PUT_TABLE_MAINTENANCE_CONFIGURATION, + s3_constants.S3TABLES_ACTION_GET_TABLE_MAINTENANCE_CONFIGURATION, + s3_constants.S3TABLES_ACTION_GET_TABLE_MAINTENANCE_JOB_STATUS, + s3_constants.S3TABLES_ACTION_TAG_RESOURCE, + s3_constants.S3TABLES_ACTION_LIST_TAGS_FOR_RESOURCE, + s3_constants.S3TABLES_ACTION_UNTAG_RESOURCE, + } +} diff --git a/weed/admin/view/app/policy_action_suggestions_test.go b/weed/admin/view/app/policy_action_suggestions_test.go new file mode 100644 index 000000000..d45cee420 --- /dev/null +++ b/weed/admin/view/app/policy_action_suggestions_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "strings" + "testing" +) + +func TestPolicyActionSuggestions_NotEmpty(t *testing.T) { + if len(PolicyActionSuggestions) == 0 { + t.Fatal("expected at least one suggestion") + } +} + +func TestPolicyActionSuggestions_NoDuplicates(t *testing.T) { + seen := make(map[string]bool, len(PolicyActionSuggestions)) + for _, action := range PolicyActionSuggestions { + if seen[action] { + t.Errorf("duplicate action suggestion: %q", action) + } + seen[action] = true + } +} + +func TestPolicyActionSuggestions_AllPrefixed(t *testing.T) { + for _, action := range PolicyActionSuggestions { + if !strings.HasPrefix(action, "s3:") && !strings.HasPrefix(action, "s3tables:") { + t.Errorf("action suggestion %q is not prefixed with a known service", action) + } + } +} + +func TestPolicyActionSuggestions_NoEmptyStrings(t *testing.T) { + for i, action := range PolicyActionSuggestions { + if strings.TrimSpace(action) == "" { + t.Errorf("suggestion at index %d is empty", i) + } + } +} diff --git a/weed/s3api/s3_constants/s3_action_strings.go b/weed/s3api/s3_constants/s3_action_strings.go index 5d96ba2c8..037324ae5 100644 --- a/weed/s3api/s3_constants/s3_action_strings.go +++ b/weed/s3api/s3_constants/s3_action_strings.go @@ -84,3 +84,64 @@ const ( // Wildcard for all S3 actions S3_ACTION_ALL = "s3:*" ) + +// S3 Tables action strings for policy evaluation. +// Source of truth for the operation names: the dispatch switch in +// weed/s3api/s3tables/handler.go. Keep this list in sync with that switch +// when operations are added, renamed, or removed. +const ( + // Table bucket operations + S3TABLES_ACTION_CREATE_TABLE_BUCKET = "s3tables:CreateTableBucket" + S3TABLES_ACTION_GET_TABLE_BUCKET = "s3tables:GetTableBucket" + S3TABLES_ACTION_LIST_TABLE_BUCKETS = "s3tables:ListTableBuckets" + S3TABLES_ACTION_DELETE_TABLE_BUCKET = "s3tables:DeleteTableBucket" + + // Table bucket policy operations + S3TABLES_ACTION_PUT_TABLE_BUCKET_POLICY = "s3tables:PutTableBucketPolicy" + S3TABLES_ACTION_GET_TABLE_BUCKET_POLICY = "s3tables:GetTableBucketPolicy" + S3TABLES_ACTION_DELETE_TABLE_BUCKET_POLICY = "s3tables:DeleteTableBucketPolicy" + + // Namespace operations + S3TABLES_ACTION_CREATE_NAMESPACE = "s3tables:CreateNamespace" + S3TABLES_ACTION_GET_NAMESPACE = "s3tables:GetNamespace" + S3TABLES_ACTION_UPDATE_NAMESPACE = "s3tables:UpdateNamespace" + S3TABLES_ACTION_LIST_NAMESPACES = "s3tables:ListNamespaces" + S3TABLES_ACTION_DELETE_NAMESPACE = "s3tables:DeleteNamespace" + + // Table operations + S3TABLES_ACTION_CREATE_TABLE = "s3tables:CreateTable" + S3TABLES_ACTION_REGISTER_TABLE = "s3tables:RegisterTable" + S3TABLES_ACTION_GET_TABLE = "s3tables:GetTable" + S3TABLES_ACTION_LIST_TABLES = "s3tables:ListTables" + S3TABLES_ACTION_UPDATE_TABLE = "s3tables:UpdateTable" + S3TABLES_ACTION_DELETE_TABLE = "s3tables:DeleteTable" + S3TABLES_ACTION_RENAME_TABLE = "s3tables:RenameTable" + + // View operations + S3TABLES_ACTION_CREATE_VIEW = "s3tables:CreateView" + S3TABLES_ACTION_GET_VIEW = "s3tables:GetView" + S3TABLES_ACTION_LIST_VIEWS = "s3tables:ListViews" + S3TABLES_ACTION_UPDATE_VIEW = "s3tables:UpdateView" + S3TABLES_ACTION_DELETE_VIEW = "s3tables:DeleteView" + S3TABLES_ACTION_RENAME_VIEW = "s3tables:RenameView" + + // Table policy operations + S3TABLES_ACTION_PUT_TABLE_POLICY = "s3tables:PutTablePolicy" + S3TABLES_ACTION_GET_TABLE_POLICY = "s3tables:GetTablePolicy" + S3TABLES_ACTION_DELETE_TABLE_POLICY = "s3tables:DeleteTablePolicy" + + // Maintenance configuration operations + S3TABLES_ACTION_PUT_TABLE_BUCKET_MAINTENANCE_CONFIGURATION = "s3tables:PutTableBucketMaintenanceConfiguration" + S3TABLES_ACTION_GET_TABLE_BUCKET_MAINTENANCE_CONFIGURATION = "s3tables:GetTableBucketMaintenanceConfiguration" + S3TABLES_ACTION_PUT_TABLE_MAINTENANCE_CONFIGURATION = "s3tables:PutTableMaintenanceConfiguration" + S3TABLES_ACTION_GET_TABLE_MAINTENANCE_CONFIGURATION = "s3tables:GetTableMaintenanceConfiguration" + S3TABLES_ACTION_GET_TABLE_MAINTENANCE_JOB_STATUS = "s3tables:GetTableMaintenanceJobStatus" + + // Tagging operations + S3TABLES_ACTION_TAG_RESOURCE = "s3tables:TagResource" + S3TABLES_ACTION_LIST_TAGS_FOR_RESOURCE = "s3tables:ListTagsForResource" + S3TABLES_ACTION_UNTAG_RESOURCE = "s3tables:UntagResource" + + // Wildcard for all S3 Tables actions + S3TABLES_ACTION_ALL = "s3tables:*" +)