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) { + + + + + + + + + + +