diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index c76815736..ba70b5e09 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -2,6 +2,7 @@ package dash import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -34,6 +35,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/s3api" "github.com/seaweedfs/seaweedfs/weed/s3api/lifecycle_xml" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler" @@ -888,6 +890,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { Owner: owner, LifecycleRuleCount: lifecycleRuleCount, LifecycleEnabledCount: lifecycleEnabledCount, + PolicyStatementCount: extractPolicyStatementCountFromEntry(resp.Entry), } buckets = append(buckets, bucket) } @@ -993,6 +996,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error details.Bucket.ObjectLockDuration = objectLockDuration details.Bucket.Owner = owner details.Bucket.LifecycleRuleCount, details.Bucket.LifecycleEnabledCount = extractLifecycleCountsFromEntry(bucketResp.Entry) + details.Bucket.PolicyStatementCount = extractPolicyStatementCountFromEntry(bucketResp.Entry) return nil }) @@ -2106,6 +2110,21 @@ func extractLifecycleCountsFromEntry(entry *filer_pb.Entry) (ruleCount, enabledC return } +// extractPolicyStatementCountFromEntry returns the number of statements in +// the bucket's policy, or 0 if it has none or the stored JSON can't be +// parsed. Forgiving on parse failure, same as extractLifecycleCountsFromEntry. +func extractPolicyStatementCountFromEntry(entry *filer_pb.Entry) int { + policyJSON := entry.Extended[s3api.BUCKET_POLICY_METADATA_KEY] + if len(policyJSON) == 0 { + return 0 + } + var doc policy_engine.PolicyDocument + if err := json.Unmarshal(policyJSON, &doc); err != nil { + return 0 + } + return len(doc.Statement) +} + // GetConfigPersistence returns the config persistence manager func (as *AdminServer) GetConfigPersistence() *ConfigPersistence { return as.configPersistence diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index 2e6072e52..07e649a50 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -13,6 +13,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" ) @@ -257,6 +258,100 @@ func validateBucketLifecycleRules(rules []BucketLifecycleRule) error { return nil } +// ShowBucketPolicy returns the policy document for a specific bucket, or +// {"bucket": ..., "policy": null} if the bucket has none. +func (s *AdminServer) ShowBucketPolicy(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] + if bucketName == "" { + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") + return + } + + policy, err := s.GetBucketPolicy(bucketName) + if err != nil { + writeJSONError(w, bucketPolicyErrorStatus(err), "Failed to get bucket policy: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "bucket": bucketName, + "policy": policy, + }) +} + +// UpdateBucketPolicy replaces the bucket policy for a bucket. +func (s *AdminServer) UpdateBucketPolicy(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } + + bucketName := mux.Vars(r)["bucket"] + if bucketName == "" { + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") + return + } + + var req struct { + Policy *policy_engine.PolicyDocument `json:"policy"` + } + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + if req.Policy == nil { + writeJSONError(w, http.StatusBadRequest, "policy is required; use DELETE to clear a bucket policy") + return + } + + if err := s.SetBucketPolicy(bucketName, req.Policy); err != nil { + writeJSONError(w, bucketPolicyErrorStatus(err), "Failed to update bucket policy: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Bucket policy updated successfully", + "bucket": bucketName, + }) +} + +// RemoveBucketPolicy clears the bucket policy for a bucket. Named +// "Remove", not "Delete", because (*AdminServer).DeleteBucketPolicy is the +// data-layer method this handler calls. +func (s *AdminServer) RemoveBucketPolicy(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } + + bucketName := mux.Vars(r)["bucket"] + if bucketName == "" { + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") + return + } + + if err := s.DeleteBucketPolicy(bucketName); err != nil { + writeJSONError(w, bucketPolicyErrorStatus(err), "Failed to delete bucket policy: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "message": "Bucket policy deleted successfully", + "bucket": bucketName, + }) +} + +// bucketPolicyErrorStatus keeps a request for a bucket that does not exist, +// or an invalid policy document, out of the 5xx bucket where a client +// would retry it. Mirrors bucketLifecycleErrorStatus. +func bucketPolicyErrorStatus(err error) int { + if errors.Is(err, ErrBucketNotFound) { + return http.StatusNotFound + } + if errors.Is(err, ErrInvalidBucketPolicy) { + return http.StatusBadRequest + } + return http.StatusInternalServerError +} + // CreateBucket creates a new S3 bucket func (s *AdminServer) CreateBucket(w http.ResponseWriter, r *http.Request) { var req CreateBucketRequest diff --git a/weed/admin/dash/bucket_policy.go b/weed/admin/dash/bucket_policy.go new file mode 100644 index 000000000..0a860f919 --- /dev/null +++ b/weed/admin/dash/bucket_policy.go @@ -0,0 +1,192 @@ +package dash + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// MaxBucketPolicySize mirrors AWS S3's 20 KB bucket-policy limit. This is an +// admin-side cap the S3 gateway does not itself enforce; it can only reject +// a policy the gateway would have accepted, never disagree about one +// already stored, so it cannot desync admin and S3 behavior. +const MaxBucketPolicySize = 20 * 1024 + +// ErrInvalidBucketPolicy wraps a validation failure from SetBucketPolicy so +// callers (the HTTP handler) can map it to 400 instead of 500 without +// resorting to matching on the error string. +var ErrInvalidBucketPolicy = errors.New("invalid bucket policy") + +// GetBucketPolicy returns the policy document stored on a bucket's filer +// entry, or (nil, nil) if the bucket has no policy — that is not an error, +// it just means the caller (e.g. the admin UI) should show an empty editor +// instead of special-casing a 404. +func (s *AdminServer) GetBucketPolicy(bucketName string) (*policy_engine.PolicyDocument, error) { + filerConfig, err := s.getFilerConfig() + if err != nil { + return nil, fmt.Errorf("get filer configuration: %w", err) + } + + var doc *policy_engine.PolicyDocument + err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{ + Directory: filerConfig.BucketsPath, + Name: bucketName, + }) + if err != nil { + if errors.Is(err, filer_pb.ErrNotFound) { + return fmt.Errorf("%w: %s", ErrBucketNotFound, bucketName) + } + return fmt.Errorf("look up bucket %s: %w", bucketName, err) + } + + policyJSON := resp.Entry.Extended[s3api.BUCKET_POLICY_METADATA_KEY] + if len(policyJSON) == 0 { + return nil + } + + var parsed policy_engine.PolicyDocument + if err := json.Unmarshal(policyJSON, &parsed); err != nil { + return fmt.Errorf("parse stored bucket policy: %w", err) + } + doc = &parsed + return nil + }) + if err != nil { + return nil, err + } + + return doc, nil +} + +// SetBucketPolicy validates and stores a bucket policy, applying the exact +// same validation the S3 gateway's PutBucketPolicy enforces +// (policy_engine.ValidatePolicy + policy_engine.ValidateBucketPolicy), so +// the admin UI and the S3 API never disagree about what's a valid policy. +// +// Propagation to every S3 gateway is automatic: writing the +// s3-bucket-policy extended attribute drives the filer metadata log, which +// each gateway's onBucketMetadataChange subscription already watches to +// rebuild its bucket policy cache. No separate notify step is needed here. +// +// Note: PutBucketPolicyHandler on the S3 gateway also mirrors the policy +// into the IAM policy store under "bucket-policy:" +// (iam_manager.go's UpdateBucketPolicy), but its delete counterpart +// (removeBucketPolicyFromIAM) is an unimplemented TODO — so that mirror is +// already unreliable after any S3-side DeleteBucketPolicy. The admin write +// path deliberately does not replicate it: doing so would only deepen an +// existing inconsistency, and admin has no handle on the S3 gateway's +// iamManager anyway. See the TODO in +// weed/s3api/s3api_bucket_policy_handlers.go for the follow-up. +func (s *AdminServer) SetBucketPolicy(bucketName string, doc *policy_engine.PolicyDocument) error { + if err := policy_engine.ValidatePolicy(doc); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidBucketPolicy, err) + } + if err := policy_engine.ValidateBucketPolicy(doc, bucketName); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidBucketPolicy, err) + } + + policyJSON, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("marshal policy document: %w", err) + } + if len(policyJSON) > MaxBucketPolicySize { + return fmt.Errorf("%w: bucket policy is %d bytes, which exceeds the %d byte limit", ErrInvalidBucketPolicy, len(policyJSON), MaxBucketPolicySize) + } + + filerConfig, err := s.getFilerConfig() + if err != nil { + return fmt.Errorf("get filer configuration: %w", err) + } + + return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + // PATCH_EXTENDED is a no-op on a missing entry, so the existence + // check has to happen here rather than fall out of the write. + if _, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{ + Directory: filerConfig.BucketsPath, + Name: bucketName, + }); err != nil { + if errors.Is(err, filer_pb.ErrNotFound) { + return fmt.Errorf("%w: %s", ErrBucketNotFound, bucketName) + } + return fmt.Errorf("look up bucket %s: %w", bucketName, err) + } + + bucketPath := filerConfig.BucketsPath + "/" + bucketName + resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{ + LockKey: bucketPath, + RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath, + Mutations: []*filer_pb.ObjectMutation{bucketPolicyMutation(filerConfig.BucketsPath, bucketName, policyJSON)}, + }) + if err != nil { + return fmt.Errorf("failed to update bucket policy: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("failed to update bucket policy: %s", resp.Error) + } + return nil + }) +} + +// DeleteBucketPolicy clears the bucket policy stored on a bucket's filer +// entry. Deleting a policy that doesn't exist is a success, matching +// DeleteBucketLifecycle's idempotent behavior. +func (s *AdminServer) DeleteBucketPolicy(bucketName string) error { + filerConfig, err := s.getFilerConfig() + if err != nil { + return fmt.Errorf("get filer configuration: %w", err) + } + + return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + if _, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{ + Directory: filerConfig.BucketsPath, + Name: bucketName, + }); err != nil { + if errors.Is(err, filer_pb.ErrNotFound) { + return fmt.Errorf("%w: %s", ErrBucketNotFound, bucketName) + } + return fmt.Errorf("look up bucket %s: %w", bucketName, err) + } + + bucketPath := filerConfig.BucketsPath + "/" + bucketName + resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{ + LockKey: bucketPath, + RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath, + Mutations: []*filer_pb.ObjectMutation{bucketPolicyMutation(filerConfig.BucketsPath, bucketName, nil)}, + }) + if err != nil { + return fmt.Errorf("failed to delete bucket policy: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("failed to delete bucket policy: %s", resp.Error) + } + return nil + }) +} + +// bucketPolicyMutation patches only the policy key rather than writing the +// whole entry back: the filer re-reads and merges under the bucket path +// lock, so a concurrent owner/quota/versioning/lifecycle change is +// preserved instead of being reverted by a stale snapshot. Same pattern as +// bucketLifecycleMutation. A nil/empty policyJSON clears the key. +func bucketPolicyMutation(bucketsPath, bucketName string, policyJSON []byte) *filer_pb.ObjectMutation { + mutation := &filer_pb.ObjectMutation{ + Type: filer_pb.ObjectMutation_PATCH_EXTENDED, + Directory: bucketsPath, + Name: bucketName, + } + if len(policyJSON) > 0 { + mutation.SetExtended = map[string][]byte{ + s3api.BUCKET_POLICY_METADATA_KEY: policyJSON, + } + return mutation + } + mutation.DeleteExtended = []string{s3api.BUCKET_POLICY_METADATA_KEY} + return mutation +} diff --git a/weed/admin/dash/bucket_policy_test.go b/weed/admin/dash/bucket_policy_test.go new file mode 100644 index 000000000..df8c19ce3 --- /dev/null +++ b/weed/admin/dash/bucket_policy_test.go @@ -0,0 +1,168 @@ +package dash + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" +) + +func validBucketPolicyJSON(bucket string) []byte { + return []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket)) +} + +func validBucketPolicyDoc(bucket string) *policy_engine.PolicyDocument { + var doc policy_engine.PolicyDocument + if err := json.Unmarshal(validBucketPolicyJSON(bucket), &doc); err != nil { + panic(err) + } + return &doc +} + +func TestBucketPolicyMutation_SetsPolicy(t *testing.T) { + policyJSON := validBucketPolicyJSON("mybucket") + m := bucketPolicyMutation("/buckets", "mybucket", policyJSON) + + if m.Type != filer_pb.ObjectMutation_PATCH_EXTENDED { + t.Fatalf("expected a PATCH_EXTENDED mutation, got %v", m.Type) + } + if m.Directory != "/buckets" || m.Name != "mybucket" { + t.Fatalf("expected the mutation to target /buckets/mybucket, got %s/%s", m.Directory, m.Name) + } + if got := m.SetExtended[s3api.BUCKET_POLICY_METADATA_KEY]; string(got) != string(policyJSON) { + t.Fatalf("expected the policy key to carry the marshaled document, got %q", got) + } + if len(m.DeleteExtended) != 0 { + t.Fatalf("expected no key deletions when saving a policy, got %v", m.DeleteExtended) + } +} + +func TestBucketPolicyMutation_ClearsKey(t *testing.T) { + m := bucketPolicyMutation("/buckets", "mybucket", nil) + + if len(m.SetExtended) != 0 { + t.Fatalf("expected no key writes when clearing, got %v", m.SetExtended) + } + if len(m.DeleteExtended) != 1 || m.DeleteExtended[0] != s3api.BUCKET_POLICY_METADATA_KEY { + t.Fatalf("expected only the policy key to be cleared, got %v", m.DeleteExtended) + } +} + +// A whole-entry write would have carried the rest of the bucket entry with +// it; the patch must name only the key it owns, so a concurrent owner, +// quota, or lifecycle change survives. +func TestBucketPolicyMutation_TouchesOnlyPolicyKey(t *testing.T) { + for _, m := range []*filer_pb.ObjectMutation{ + bucketPolicyMutation("/buckets", "mybucket", validBucketPolicyJSON("mybucket")), + bucketPolicyMutation("/buckets", "mybucket", nil), + } { + if m.Entry != nil { + t.Fatal("expected the mutation to carry no entry snapshot") + } + if m.SetContent { + t.Fatal("expected the mutation to leave entry content alone") + } + for k := range m.SetExtended { + if k != s3api.BUCKET_POLICY_METADATA_KEY { + t.Fatalf("unexpected key written: %s", k) + } + } + for _, k := range m.DeleteExtended { + if k != s3api.BUCKET_POLICY_METADATA_KEY { + t.Fatalf("unexpected key deleted: %s", k) + } + } + } +} + +func TestExtractPolicyStatementCountFromEntry(t *testing.T) { + tests := []struct { + name string + entry *filer_pb.Entry + want int + }{ + {"no extended attrs", &filer_pb.Entry{}, 0}, + {"absent key", &filer_pb.Entry{Extended: map[string][]byte{"other": []byte("x")}}, 0}, + {"one statement", &filer_pb.Entry{Extended: map[string][]byte{ + s3api.BUCKET_POLICY_METADATA_KEY: validBucketPolicyJSON("b"), + }}, 1}, + {"three statements", &filer_pb.Entry{Extended: map[string][]byte{ + s3api.BUCKET_POLICY_METADATA_KEY: []byte(`{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}, + {"Effect":"Allow","Principal":"*","Action":"s3:PutObject","Resource":"arn:aws:s3:::b/*"}, + {"Effect":"Deny","Principal":"*","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::b/*"} + ]}`), + }}, 3}, + {"garbage bytes", &filer_pb.Entry{Extended: map[string][]byte{ + s3api.BUCKET_POLICY_METADATA_KEY: []byte("not json"), + }}, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractPolicyStatementCountFromEntry(tt.entry); got != tt.want { + t.Errorf("extractPolicyStatementCountFromEntry() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestBucketPolicyErrorStatus(t *testing.T) { + if got := bucketPolicyErrorStatus(fmt.Errorf("%w: mybucket", ErrBucketNotFound)); got != http.StatusNotFound { + t.Fatalf("expected a missing bucket to map to 404, got %d", got) + } + if got := bucketPolicyErrorStatus(fmt.Errorf("%w: bad statement", ErrInvalidBucketPolicy)); got != http.StatusBadRequest { + t.Fatalf("expected an invalid policy to map to 400, got %d", got) + } + if got := bucketPolicyErrorStatus(errors.New("filer unreachable")); got != http.StatusInternalServerError { + t.Fatalf("expected an unrelated failure to stay 500, got %d", got) + } +} + +func TestSetBucketPolicy_RejectsOversized(t *testing.T) { + // A resource list long enough to blow the cap: this must fail before + // any filer call, which is what makes it testable without one. + doc := validBucketPolicyDoc("mybucket") + doc.Statement[0].Sid = strings.Repeat("x", MaxBucketPolicySize+1) + + err := (&AdminServer{}).SetBucketPolicy("mybucket", doc) + if err == nil { + t.Fatal("expected an oversized bucket policy to be rejected") + } + if !errors.Is(err, ErrInvalidBucketPolicy) { + t.Fatalf("expected an ErrInvalidBucketPolicy, got: %v", err) + } +} + +func TestSetBucketPolicy_RejectsForeignResource(t *testing.T) { + // Proves the shared policy_engine.ValidateBucketPolicy validator is + // actually wired in: this is exactly the check the S3 gateway applies. + doc := validBucketPolicyDoc("mybucket") + doc.Statement[0].Resource = policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*") + + err := (&AdminServer{}).SetBucketPolicy("mybucket", doc) + if err == nil { + t.Fatal("expected a policy referencing a different bucket to be rejected") + } + if !errors.Is(err, ErrInvalidBucketPolicy) { + t.Fatalf("expected an ErrInvalidBucketPolicy, got: %v", err) + } +} + +func TestSetBucketPolicy_RejectsMissingPrincipal(t *testing.T) { + doc := validBucketPolicyDoc("mybucket") + doc.Statement[0].Principal = nil + + err := (&AdminServer{}).SetBucketPolicy("mybucket", doc) + if err == nil { + t.Fatal("expected a policy with no Principal to be rejected") + } + if !errors.Is(err, ErrInvalidBucketPolicy) { + t.Fatalf("expected an ErrInvalidBucketPolicy, got: %v", err) + } +} diff --git a/weed/admin/dash/s3tables_management.go b/weed/admin/dash/s3tables_management.go index d1f1c641b..78c35bb03 100644 --- a/weed/admin/dash/s3tables_management.go +++ b/weed/admin/dash/s3tables_management.go @@ -37,6 +37,12 @@ type S3TablesBucketSummary struct { // Format is empty for a bucket created before formats were declared. Such a // bucket takes tables of either format, which is what it always did. Format string `json:"format,omitempty"` + // PolicyStatementCount is the number of statements in the table bucket's + // resource policy, or 0 if it has none. Unrelated to the S3 bucket + // policy mechanism (policy_engine.PolicyDocument / s3-bucket-policy): + // S3 Tables stores its own s3tables.PolicyDocument under the + // s3tables.policy extended attribute. + PolicyStatementCount int `json:"policy_statement_count"` } type S3TablesNamespacesData struct { @@ -144,11 +150,12 @@ func (s *AdminServer) GetS3TablesBucketsData(ctx context.Context) (S3TablesBucke continue } buckets = append(buckets, S3TablesBucketSummary{ - ARN: arn, - Name: entry.Entry.Name, - OwnerAccountID: metadata.OwnerAccountID, - CreatedAt: metadata.CreatedAt, - Format: metadata.Format, + ARN: arn, + Name: entry.Entry.Name, + OwnerAccountID: metadata.OwnerAccountID, + CreatedAt: metadata.CreatedAt, + Format: metadata.Format, + PolicyStatementCount: extractS3TablesPolicyStatementCountFromEntry(entry.Entry), }) } return nil @@ -165,6 +172,23 @@ func (s *AdminServer) GetS3TablesBucketsData(ctx context.Context) (S3TablesBucke }, nil } +// extractS3TablesPolicyStatementCountFromEntry returns the number of +// statements in the table bucket's resource policy, or 0 if it has none or +// the stored JSON can't be parsed. Forgiving on parse failure, matching +// extractPolicyStatementCountFromEntry (the S3 bucket policy equivalent in +// admin_server.go, which is a different, unrelated policy mechanism). +func extractS3TablesPolicyStatementCountFromEntry(entry *filer_pb.Entry) int { + policyJSON := entry.Extended[s3tables.ExtendedKeyPolicy] + if len(policyJSON) == 0 { + return 0 + } + var doc s3tables.PolicyDocument + if err := json.Unmarshal(policyJSON, &doc); err != nil { + return 0 + } + return len(doc.Statement) +} + // observedRowCounts collects what workers last reported for these tables. For a // format admin cannot read, this is the only row count that exists. func (s *AdminServer) observedRowCounts(bucketArn string, namespaceParts []string, tables []s3tables.TableSummary) map[string]string { diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index a161f8bf7..b30da7961 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -98,6 +98,12 @@ type S3Bucket struct { LifecycleRuleCount int `json:"lifecycle_rule_count"` LifecycleEnabledCount int `json:"lifecycle_enabled_count"` + + // PolicyStatementCount is the number of statements in the bucket policy, + // or 0 if the bucket has none. A policy document can't have zero + // statements (see policy_engine.ValidatePolicy), so >0 is a faithful + // "has a policy" flag. + PolicyStatementCount int `json:"policy_statement_count"` } type S3Object struct { diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 767790764..67131b4b7 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -183,6 +183,9 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { s3Api.Handle("/buckets/{bucket}/lifecycle", wrapWrite(h.adminServer.DeleteBucketLifecycle)).Methods(http.MethodDelete) s3Api.Handle("/buckets/{bucket}/quota", wrapWrite(h.adminServer.UpdateBucketQuota)).Methods(http.MethodPut) s3Api.Handle("/buckets/{bucket}/owner", wrapWrite(h.adminServer.UpdateBucketOwner)).Methods(http.MethodPut) + s3Api.HandleFunc("/buckets/{bucket}/policy", h.adminServer.ShowBucketPolicy).Methods(http.MethodGet) + s3Api.Handle("/buckets/{bucket}/policy", wrapWrite(h.adminServer.UpdateBucketPolicy)).Methods(http.MethodPut) + s3Api.Handle("/buckets/{bucket}/policy", wrapWrite(h.adminServer.RemoveBucketPolicy)).Methods(http.MethodDelete) usersApi := api.PathPrefix("/users").Subrouter() usersApi.HandleFunc("", h.userHandlers.GetUsers).Methods(http.MethodGet) diff --git a/weed/admin/handlers/admin_handlers_routes_test.go b/weed/admin/handlers/admin_handlers_routes_test.go index 9cbd35528..1cc0728ca 100644 --- a/weed/admin/handlers/admin_handlers_routes_test.go +++ b/weed/admin/handlers/admin_handlers_routes_test.go @@ -62,6 +62,26 @@ func TestSetupRoutes_RegistersBucketLifecycleAPI_WithAuth(t *testing.T) { assertHasRoute(t, router, http.MethodDelete, "/api/s3/buckets/example/lifecycle") } +func TestSetupRoutes_RegistersBucketPolicyAPI_NoAuth(t *testing.T) { + router := mux.NewRouter() + + newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) + + assertHasRoute(t, router, http.MethodGet, "/api/s3/buckets/example/policy") + assertHasRoute(t, router, http.MethodPut, "/api/s3/buckets/example/policy") + assertHasRoute(t, router, http.MethodDelete, "/api/s3/buckets/example/policy") +} + +func TestSetupRoutes_RegistersBucketPolicyAPI_WithAuth(t *testing.T) { + router := mux.NewRouter() + + newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true) + + assertHasRoute(t, router, http.MethodGet, "/api/s3/buckets/example/policy") + assertHasRoute(t, router, http.MethodPut, "/api/s3/buckets/example/policy") + assertHasRoute(t, router, http.MethodDelete, "/api/s3/buckets/example/policy") +} + func TestSetupRoutes_RegistersPolicyAPI_NoAuth(t *testing.T) { router := mux.NewRouter() diff --git a/weed/admin/static/js/policy_editor.js b/weed/admin/static/js/policy_editor.js new file mode 100644 index 000000000..0f914ed77 --- /dev/null +++ b/weed/admin/static/js/policy_editor.js @@ -0,0 +1,886 @@ +// Shared visual policy editor: renders and edits an IAM/bucket policy +// document (Version + Statement list) via a structured form alongside a +// raw-JSON tab, kept in sync in both directions. +// +// Extracted from weed/admin/view/app/policies.templ (the IAM policy +// management page), which was its original and, for a while, only +// consumer. Any page embedding this editor must first render the shared +// datalists (see the PolicyDatalists templ component in +// weed/admin/view/app/policy_datalists.templ) and load this script after +// admin.js (for basePath/escapeHtml) and modal-alerts.js (for showAlert). +// +// Usage: call registerPolicyEditor(which, config) once to declare an +// editor instance (see its doc comment for the id conventions and +// config knobs), then setupPolicyEditor(which) once to wire up its DOM +// listeners. which is an arbitrary string ("create", "edit", +// "bucketPolicy", ...) that namespaces one editor instance's DOM ids and +// state from another's on the same page. + +// Per-`which` editor configuration. See registerPolicyEditor. +const POLICY_EDITOR_CONFIG = {}; + +// registerPolicyEditor declares (or redeclares) the configuration for one +// editor instance. Call before setupPolicyEditor(which), and again any +// time a config value (e.g. `bucket`) needs to change for an +// already-set-up instance (setupPolicyEditor only needs to run once per +// `which`; its DOM listeners read POLICY_EDITOR_CONFIG live). +// +// config: +// textareaId - id of the JSON ' + + '' + + '' + + ''; + }); + container.innerHTML = html; + } + + function policyListRowHtml(which, stmtIdx, field, itemIdx, value) { + const cfg = policyEditorConfig(which); + let listAttr = ''; + if (field === 'action') listAttr = ' list="' + cfg.actionDatalistId + '"'; + else if (field === 'resource') listAttr = ' list="' + cfg.resourceDatalistId + '"'; + else if (field === 'principal') listAttr = ' list="' + cfg.principalDatalistId + '"'; + return '
' + + '' + + '' + + '
'; + } + + // Reads whatever is currently displayed in the editor tab's DOM back into + // policyEditors[which], so nothing typed is lost before a save/tab-switch/serialize. + function commitPolicyEditorForm(which) { + const state = policyEditors[which]; + if (!state) return; + + document.querySelectorAll('.policy-stmt-sid[data-which="' + which + '"]').forEach(function(el) { + const idx = parseInt(el.getAttribute('data-index'), 10); + if (state.statements[idx]) state.statements[idx].sid = el.value; + }); + document.querySelectorAll('.policy-stmt-effect[data-which="' + which + '"]:checked').forEach(function(el) { + const idx = parseInt(el.getAttribute('data-index'), 10); + if (state.statements[idx]) state.statements[idx].effect = el.value; + }); + document.querySelectorAll('.policy-stmt-extras[data-which="' + which + '"]').forEach(function(el) { + const idx = parseInt(el.getAttribute('data-index'), 10); + if (state.statements[idx]) state.statements[idx].extras = el.value; + }); + document.querySelectorAll('.policy-stmt-resource-mode[data-which="' + which + '"]').forEach(function(el) { + const idx = parseInt(el.getAttribute('data-index'), 10); + if (state.statements[idx]) state.statements[idx].resourceMode = el.value; + }); + document.querySelectorAll('.policy-stmt-principal-mode[data-which="' + which + '"]').forEach(function(el) { + const idx = parseInt(el.getAttribute('data-index'), 10); + if (state.statements[idx]) state.statements[idx].principalMode = el.value; + }); + document.querySelectorAll('.policy-list-item[data-which="' + which + '"]').forEach(function(el) { + const idx = parseInt(el.getAttribute('data-index'), 10); + const itemIdx = parseInt(el.getAttribute('data-item-index'), 10); + const field = POLICY_LIST_FIELD_TO_STATE_KEY[el.getAttribute('data-field')] || 'resources'; + if (state.statements[idx] && state.statements[idx][field]) { + state.statements[idx][field][itemIdx] = el.value; + } + }); + } + + // Serializes policyEditors[which] into the JSON textarea. Call before + // switching to the JSON tab or before submitting, so the textarea always + // reflects the editor's current contents. + function commitPolicyEditorToTextarea(which) { + commitPolicyEditorForm(which); + const doc = policyEditorStateToDoc(policyEditors[which]); + document.getElementById(policyTextareaId(which)).value = JSON.stringify(doc, null, 2); + } + + // Parses the JSON textarea into policyEditors[which] and re-renders the + // editor. Returns false (and shows an alert) if the JSON is invalid or a + // statement's Effect isn't exactly "Allow"/"Deny", leaving the JSON tab + // as the active one so the user can fix it. + function commitPolicyTextareaToEditor(which) { + const text = document.getElementById(policyTextareaId(which)).value; + if (!text || !text.trim()) { + policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {} }; + renderPolicyEditor(which); + return true; + } + let doc; + try { + doc = JSON.parse(text); + } catch (e) { + showAlert('Invalid JSON in policy document: ' + e.message, 'error'); + return false; + } + let newState; + try { + newState = policyDocToEditorState(doc); + } catch (e) { + showAlert(e.message, 'error'); + return false; + } + policyEditors[which] = newState; + renderPolicyEditor(which); + return true; + } + + function activatePolicyTab(idKey, which) { + const btn = document.getElementById(policyEditorConfig(which)[idKey]); + if (btn) bootstrap.Tab.getOrCreateInstance(btn).show(); + } + + // Populates the editor for `which` from whatever is currently in its + // JSON textarea (typically right after a GET fills the textarea) and + // switches to whichever tab can actually show the result. + // + // Unlike commitPolicyTextareaToEditor - which assumes a tab is already + // showing and leaves it in place on failure so a Save can't silently + // clobber it - this function has no "current tab" to defer to: it is + // the thing that establishes one. So on a document the structured + // editor can't represent (invalid JSON, or valid JSON + // policyDocToEditorState rejects), it marks the state `unparsed` and + // switches to the JSON tab instead of leaving the Editor tab showing + // empty/stale state that a careless Save would serialize over the + // real document. Mirrors editPolicy's fallback in policies.templ. + function loadPolicyTextareaIntoEditor(which) { + const text = document.getElementById(policyTextareaId(which)).value; + if (!text || !text.trim()) { + policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {} }; + renderPolicyEditor(which); + activatePolicyTab('editorTabBtnId', which); + return true; + } + let doc; + try { + doc = JSON.parse(text); + } catch (e) { + policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {}, unparsed: true }; + renderPolicyEditor(which); + showAlert('Invalid JSON in stored policy: ' + e.message + '. ' + POLICY_JSON_TAB_ONLY_MESSAGE, 'error'); + activatePolicyTab('jsonTabBtnId', which); + return false; + } + let state; + try { + state = policyDocToEditorState(doc); + } catch (e) { + policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {}, unparsed: true }; + renderPolicyEditor(which); + showAlert(e.message + '. ' + POLICY_JSON_TAB_ONLY_MESSAGE, 'error'); + activatePolicyTab('jsonTabBtnId', which); + return false; + } + policyEditors[which] = state; + renderPolicyEditor(which); + activatePolicyTab('editorTabBtnId', which); + return true; + } + + function addPolicyStatement(which) { + if (policyEditorState(which).unparsed) { + showAlert(POLICY_JSON_TAB_ONLY_MESSAGE, 'error'); + return; + } + const cfg = policyEditorConfig(which); + commitPolicyEditorForm(which); + policyEditorState(which).statements.push({ + sid: '', effect: 'Allow', actions: [], + resourceMode: 'Resource', resources: cfg.bucket ? ['arn:aws:s3:::' + cfg.bucket + '/*'] : [], + principalMode: 'Principal', principalValues: cfg.requirePrincipal ? ['*'] : [], hasComplexPrincipal: false, + extras: '' + }); + renderPolicyEditor(which); + } + + // True while the JSON tab (rather than the Editor tab) is the one + // currently shown for `which`. + function isPolicyJsonTabActive(which) { + const jsonTabBtn = document.getElementById(policyEditorConfig(which).jsonTabBtnId); + return !!(jsonTabBtn && jsonTabBtn.classList.contains('active')); + } + + // Commits whichever tab is currently visible into the other side, so a + // save/validate action always uses what the user is actually looking at + // instead of silently overwriting it with stale state from the tab + // they're not on. Returns false (after alerting the user) if that isn't + // possible - e.g. invalid JSON on either side - so the caller can abort. + function commitPolicyActiveTab(which) { + if (isPolicyJsonTabActive(which)) { + // The JSON tab is the source of truth right now; parse it back + // into the structured editor to keep both in sync, but leave the + // textarea's own text untouched. + return commitPolicyTextareaToEditor(which); + } + if (policyEditorState(which).unparsed) { + // The editor never held this document, so serializing it would + // write an empty policy over whatever is in the JSON tab. + showAlert(POLICY_JSON_TAB_ONLY_MESSAGE, 'error'); + return false; + } + try { + commitPolicyEditorToTextarea(which); + return true; + } catch (e) { + showAlert(e.message, 'error'); + return false; + } + } + + // The admin API's policy document carries only Version and Statement, so + // any other top-level key (e.g. Id) is discarded server-side on save even + // though the editor round-trips it between tabs. Warn before that happens + // rather than letting the field vanish silently. Returns false if the + // user cancels. + function confirmPolicyFieldDiscard(which) { + const otherFields = Object.keys((policyEditors[which] || {}).otherFields || {}); + if (otherFields.length === 0) return true; + return confirm( + 'The following top-level field(s) are not supported and will be dropped when this policy is saved: ' + + otherFields.join(', ') + '.\n\nSave anyway?'); + } + + // Client-side check for the requirePrincipal config knob: returns an + // error message naming the first statement missing a Principal / + // NotPrincipal, or null if the document is fine. Purely a fast-feedback + // convenience - the server (policy_engine.ValidateBucketPolicy) is the + // actual authority on this rule and re-checks it regardless. + function validatePolicyEditorDoc(which, doc) { + if (!policyEditorConfig(which).requirePrincipal) return null; + const statements = (doc && doc.Statement) || []; + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i] || {}; + if (stmt.Principal === undefined && stmt.NotPrincipal === undefined) { + return 'Statement ' + (i + 1) + ': a Principal (or NotPrincipal) is required.'; + } + } + return null; + } + + function setupPolicyEditor(which) { + const cfg = policyEditorConfig(which); + document.getElementById(cfg.addStatementBtnId).addEventListener('click', function() { + addPolicyStatement(which); + }); + + const editorTabBtn = document.getElementById(cfg.editorTabBtnId); + const jsonTabBtn = document.getElementById(cfg.jsonTabBtnId); + + jsonTabBtn.addEventListener('show.bs.tab', function(event) { + if (policyEditorState(which).unparsed) { + // The editor never held this document; serializing its empty + // placeholder state would overwrite the textarea we are about + // to show, which is the only copy of it. + return; + } + try { + commitPolicyEditorToTextarea(which); + } catch (e) { + showAlert(e.message, 'error'); + event.preventDefault(); + } + }); + editorTabBtn.addEventListener('show.bs.tab', function(event) { + if (!commitPolicyTextareaToEditor(which)) { + event.preventDefault(); + } + }); + + const body = document.getElementById(policyEditorBodyId(which)); + body.addEventListener('change', function(event) { + if (event.target.classList.contains('policy-stmt-resource-mode')) { + // Redraw so the NotResource hint follows the selected mode. + commitPolicyEditorForm(which); + renderPolicyEditor(which); + } + }); + body.addEventListener('click', function(event) { + const removeStmtBtn = event.target.closest('.policy-remove-statement-btn'); + if (removeStmtBtn) { + commitPolicyEditorForm(which); + const idx = parseInt(removeStmtBtn.getAttribute('data-index'), 10); + policyEditors[which].statements.splice(idx, 1); + renderPolicyEditor(which); + return; + } + const addItemBtn = event.target.closest('.policy-add-list-item-btn'); + if (addItemBtn) { + commitPolicyEditorForm(which); + const idx = parseInt(addItemBtn.getAttribute('data-index'), 10); + const field = POLICY_LIST_FIELD_TO_STATE_KEY[addItemBtn.getAttribute('data-field')] || 'resources'; + policyEditors[which].statements[idx][field].push(''); + renderPolicyEditor(which); + return; + } + const removeItemBtn = event.target.closest('.policy-remove-list-item-btn'); + if (removeItemBtn) { + commitPolicyEditorForm(which); + const idx = parseInt(removeItemBtn.getAttribute('data-index'), 10); + const itemIdx = parseInt(removeItemBtn.getAttribute('data-item-index'), 10); + const field = POLICY_LIST_FIELD_TO_STATE_KEY[removeItemBtn.getAttribute('data-field')] || 'resources'; + policyEditors[which].statements[idx][field].splice(itemIdx, 1); + renderPolicyEditor(which); + } + }); + + // Populate the shared Resource datalist as the user types/focuses a + // Resource field. Bootstrap's datalist filtering then narrows down + // whatever set of options was last loaded for the current path stage. + body.addEventListener('input', function(event) { + const target = event.target; + if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'resource') { + updatePolicyResourceSuggestions(which, target); + } + }); + body.addEventListener('focusin', function(event) { + const target = event.target; + if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'resource') { + updatePolicyResourceSuggestions(which, target); + } + }); + + // Same idea for the shared Principal datalist: a flat, one-time + // fetch (see loadPolicyPrincipalSuggestions), no per-segment logic + // needed since users/roles aren't hierarchical like bucket paths. + body.addEventListener('input', function(event) { + const target = event.target; + if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'principal') { + updatePolicyPrincipalSuggestions(which); + } + }); + body.addEventListener('focusin', function(event) { + const target = event.target; + if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'principal') { + updatePolicyPrincipalSuggestions(which); + } + }); + } + + // ------------------------------------------------------------------ + // Progressive Resource ARN autocomplete: suggests bucket names first + // (arn:aws:s3:::bucket), then once a bucket + "/" is typed, suggests + // arn:aws:s3:::bucket/* plus the direct subfolders one path segment at a + // time (fetched from the server on demand, one directory level per + // request, and cached per directory for the life of the page). + // ------------------------------------------------------------------ + + const POLICY_RESOURCE_ARN_PREFIX = 'arn:aws:s3:::'; + let policyBucketArnsPromise = null; + const policyFolderListCache = new Map(); + + function loadPolicyBucketArns() { + if (!policyBucketArnsPromise) { + policyBucketArnsPromise = fetch(basePath('/api/s3/buckets')) + .then(function(r) { return r.ok ? r.json() : { buckets: [] }; }) + .then(function(data) { + // Offer both the bucket itself and "every object in it", + // since the latter is what most Resource entries actually need. + return (data.buckets || []).reduce(function(acc, b) { + const arn = POLICY_RESOURCE_ARN_PREFIX + b.name; + acc.push(arn, arn + '/*'); + return acc; + }, []); + }) + .catch(function() { return []; }); + } + return policyBucketArnsPromise; + } + + function loadPolicyFolderNames(dirPath, prefix) { + // Send the segment still being typed as a prefix so the filer does the + // filtering: without it the server pages through every entry in the + // directory, which on a bucket of flat object keys is the whole bucket. + const key = dirPath + '\n' + prefix; + if (!policyFolderListCache.has(key)) { + policyFolderListCache.set(key, fetch(basePath('/api/files/list-folders?path=' + encodeURIComponent(dirPath) + + '&prefix=' + encodeURIComponent(prefix))) + .then(function(r) { + if (!r.ok) throw new Error('list-folders request failed with status ' + r.status); + return r.json(); + }) + .then(function(data) { return data.folders || []; }) + .catch(function() { + // Don't let a transient failure permanently poison the + // cache for this directory; let the next call retry. + policyFolderListCache.delete(key); + return []; + })); + } + return policyFolderListCache.get(key); + } + + // Figures out what stage of the ARN the user is currently typing: + // still the bucket name ("bucket"), or a folder path segment after the + // bucket ("folder", with dirPath being the filer directory to list and + // arnPrefix being the ARN text to append suggestions onto). + function policyResourcePathState(value) { + value = value || ''; + if (value.indexOf(POLICY_RESOURCE_ARN_PREFIX) !== 0) { + return { stage: 'bucket' }; + } + const rest = value.slice(POLICY_RESOURCE_ARN_PREFIX.length); + const segments = rest.split('/'); + if (segments.length === 1) { + return { stage: 'bucket' }; + } + const bucket = segments[0]; + const pathSegments = segments.slice(1, segments.length - 1); + const suffix = pathSegments.length ? '/' + pathSegments.join('/') : ''; + return { + stage: 'folder', + dirPath: '/buckets/' + bucket + suffix, + // The trailing, still-incomplete segment. The datalist narrows on + // it too, but sending it keeps the server's listing bounded. + prefix: segments[segments.length - 1], + arnPrefix: POLICY_RESOURCE_ARN_PREFIX + bucket + suffix + }; + } + + function renderPolicyDatalistOptions(datalist, values) { + datalist.innerHTML = values.map(function(v) { + return ''; + }).join(''); + } + + function updatePolicyResourceSuggestions(which, inputEl) { + const cfg = policyEditorConfig(which); + const datalist = document.getElementById(cfg.resourceDatalistId); + if (!datalist) return; + const state = policyResourcePathState(inputEl.value); + + if (state.stage === 'bucket') { + if (cfg.bucket) { + // Pinned to one bucket: no need to fetch and offer every + // bucket in the cluster, and the user can't be offered an + // ARN the server would reject anyway (see + // policy_engine.ValidateBucketPolicy). + renderPolicyDatalistOptions(datalist, [ + POLICY_RESOURCE_ARN_PREFIX + cfg.bucket, + POLICY_RESOURCE_ARN_PREFIX + cfg.bucket + '/*' + ]); + return; + } + loadPolicyBucketArns().then(function(arns) { + renderPolicyDatalistOptions(datalist, arns); + }); + return; + } + + loadPolicyFolderNames(state.dirPath, state.prefix).then(function(folders) { + const options = [state.arnPrefix + '/*']; + folders.forEach(function(name) { + options.push(state.arnPrefix + '/' + name); + }); + renderPolicyDatalistOptions(datalist, options); + }); + } + + // ------------------------------------------------------------------ + // Principal autocomplete: a flat list of existing users and IAM roles, + // fetched once from /api/principals and cached for the life of the page + // (unlike Resource ARNs, users/roles have no hierarchy to drill into). + // ------------------------------------------------------------------ + + let policyPrincipalSuggestionsPromise = null; + + function loadPolicyPrincipalSuggestions() { + if (!policyPrincipalSuggestionsPromise) { + policyPrincipalSuggestionsPromise = fetch(basePath('/api/principals')) + .then(function(r) { return r.ok ? r.json() : { principals: [] }; }) + .then(function(data) { return ['*'].concat(data.principals || []); }) + .catch(function() { return ['*']; }); + } + return policyPrincipalSuggestionsPromise; + } + + function updatePolicyPrincipalSuggestions(which) { + const datalist = document.getElementById(policyEditorConfig(which).principalDatalistId); + if (!datalist) return; + loadPolicyPrincipalSuggestions().then(function(principals) { + renderPolicyDatalistOptions(datalist, principals); + }); + } + + // Fills the structured editor (and the JSON tab) with a sample policy, + // regardless of which tab is currently active. + const POLICY_SAMPLE_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::my-bucket/*" + ] + } + ] + }; + + function insertSamplePolicy(which, sampleDoc) { + const doc = sampleDoc || POLICY_SAMPLE_DOCUMENT; + policyEditors[which] = policyDocToEditorState(doc); + renderPolicyEditor(which); + document.getElementById(policyTextareaId(which)).value = JSON.stringify(doc, null, 2); + } diff --git a/weed/admin/static/js/s3tables.js b/weed/admin/static/js/s3tables.js index 2ea7d9709..f143a71da 100644 --- a/weed/admin/static/js/s3tables.js +++ b/weed/admin/static/js/s3tables.js @@ -15,6 +15,23 @@ let s3tablesTablePolicyModal = null; let s3tablesTagsModal = null; let icebergTableDeleteModal = null; +// True only once a bucket/table policy GET has actually completed +// successfully (a genuinely empty policy counts). Guards the Save handlers +// below: a failed GET must not let a Save serialize the editor's cleared-out +// placeholder state as a real "Statement: []" document and overwrite +// whatever is actually stored. +let s3tablesBucketPolicyLoaded = false; +let s3tablesTablePolicyLoaded = false; + +// Bumped on every bucket/table policy load; a response only gets applied if +// its captured sequence number still matches. Without this, opening one +// resource's policy dialog and then another's before the first GET resolves +// lets the late response overwrite the second resource's textarea/editor +// state and mark it loaded, so a subsequent Save would push the first +// resource's policy onto the second resource. +let s3tablesBucketPolicyRequestSeq = 0; +let s3tablesTablePolicyRequestSeq = 0; + function getCSRFToken() { const tokenMeta = document.querySelector('meta[name="csrf-token"]'); if (!tokenMeta) { @@ -40,6 +57,15 @@ function initS3TablesBuckets() { s3tablesBucketPolicyModal = new bootstrap.Modal(document.getElementById('s3tablesBucketPolicyModal')); s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal')); + // Shared visual policy editor (weed/admin/static/js/policy_editor.js), + // reused here from the bucket policy admin page. Table bucket policies + // aren't validated against policy_engine.PolicyDocument server-side + // (see s3tables/permissions.go's separate PolicyDocument type), so no + // requirePrincipal/bucket config is set - the editor just gives a + // structured view over the same JSON the JSON tab holds. + registerPolicyEditor('s3tablesBucket', { textareaId: 's3tablesBucketPolicyText' }); + setupPolicyEditor('s3tablesBucket'); + const ownerSelect = document.getElementById('s3tablesBucketOwner'); if (ownerSelect) { document.getElementById('createS3TablesBucketModal').addEventListener('show.bs.modal', async function () { @@ -179,6 +205,11 @@ function initS3TablesBuckets() { if (policyForm) { policyForm.addEventListener('submit', async function (e) { e.preventDefault(); + if (!s3tablesBucketPolicyLoaded) { + alert('The current policy has not finished loading. Close and reopen this dialog before saving.'); + return; + } + if (!commitPolicyActiveTab('s3tablesBucket')) return; const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value; const policy = document.getElementById('s3tablesBucketPolicyText').value.trim(); if (!policy) { @@ -227,6 +258,9 @@ function initS3TablesTables() { s3tablesTablePolicyModal = new bootstrap.Modal(document.getElementById('s3tablesTablePolicyModal')); s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal')); + registerPolicyEditor('s3tablesTable', { textareaId: 's3tablesTablePolicyText' }); + setupPolicyEditor('s3tablesTable'); + const dataContainer = document.getElementById('s3tables-tables-content'); const dataBucketArn = dataContainer.dataset.bucketArn || ''; const dataNamespace = dataContainer.dataset.namespace || ''; @@ -314,6 +348,11 @@ function initS3TablesTables() { if (policyForm) { policyForm.addEventListener('submit', async function (e) { e.preventDefault(); + if (!s3tablesTablePolicyLoaded) { + alert('The current policy has not finished loading. Close and reopen this dialog before saving.'); + return; + } + if (!commitPolicyActiveTab('s3tablesTable')) return; const policy = document.getElementById('s3tablesTablePolicyText').value.trim(); if (!policy) { alert('Policy JSON is required'); @@ -581,22 +620,51 @@ async function deleteS3TablesBucket() { } async function loadS3TablesBucketPolicy(bucketArn) { + const requestSeq = ++s3tablesBucketPolicyRequestSeq; document.getElementById('s3tablesBucketPolicyText').value = ''; - if (!bucketArn) return; - try { - const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`)); - const data = await response.json(); - if (response.ok && data.policy) { - document.getElementById('s3tablesBucketPolicyText').value = data.policy; + s3tablesBucketPolicyLoaded = false; + // Reset the structured editor immediately too, so a still-open Editor + // tab doesn't keep showing the previously loaded resource's statements + // while this fetch is in flight. + loadPolicyTextareaIntoEditor('s3tablesBucket'); + if (bucketArn) { + let policyText = ''; + let loadError = null; + try { + const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`)); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || ('HTTP ' + response.status)); + } + if (data.policy) { + policyText = data.policy; + } + } catch (error) { + loadError = error; } - } catch (error) { - console.error('Failed to load bucket policy', error); + // A newer load (a different bucket, or this one reopened) has since + // superseded this response - don't let it touch the shared textarea, + // the editor state, or the loaded flag. + if (requestSeq !== s3tablesBucketPolicyRequestSeq) return; + if (loadError) { + console.error('Failed to load bucket policy', loadError); + alert('Failed to load bucket policy: ' + loadError.message + '. Close and reopen this dialog to try again.'); + return; + } + document.getElementById('s3tablesBucketPolicyText').value = policyText; } + if (requestSeq !== s3tablesBucketPolicyRequestSeq) return; + s3tablesBucketPolicyLoaded = true; + loadPolicyTextareaIntoEditor('s3tablesBucket'); } async function deleteS3TablesBucketPolicy() { const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value; if (!bucketArn) return; + if (!s3tablesBucketPolicyLoaded) { + alert('The current policy has not finished loading. Close and reopen this dialog before deleting.'); + return; + } try { const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE', headers: s3tWriteHeaders() }); const data = await response.json(); @@ -606,6 +674,7 @@ async function deleteS3TablesBucketPolicy() { } alert('Policy deleted'); document.getElementById('s3tablesBucketPolicyText').value = ''; + commitPolicyTextareaToEditor('s3tablesBucket'); } catch (error) { alert('Failed to delete policy: ' + error.message); } @@ -677,21 +746,50 @@ async function deleteIcebergTable() { } async function loadS3TablesTablePolicy(bucketArn, namespace, name) { + const requestSeq = ++s3tablesTablePolicyRequestSeq; document.getElementById('s3tablesTablePolicyText').value = ''; - if (!bucketArn || !namespace || !name) return; - const query = new URLSearchParams({ bucket: bucketArn, namespace: namespace, name: name }); - try { - const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`)); - const data = await response.json(); - if (response.ok && data.policy) { - document.getElementById('s3tablesTablePolicyText').value = data.policy; + s3tablesTablePolicyLoaded = false; + // Reset the structured editor immediately too, so a still-open Editor + // tab doesn't keep showing the previously loaded resource's statements + // while this fetch is in flight. + loadPolicyTextareaIntoEditor('s3tablesTable'); + if (bucketArn && namespace && name) { + const query = new URLSearchParams({ bucket: bucketArn, namespace: namespace, name: name }); + let policyText = ''; + let loadError = null; + try { + const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`)); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || ('HTTP ' + response.status)); + } + if (data.policy) { + policyText = data.policy; + } + } catch (error) { + loadError = error; } - } catch (error) { - console.error('Failed to load table policy', error); + // A newer load (a different table, or this one reopened) has since + // superseded this response - don't let it touch the shared textarea, + // the editor state, or the loaded flag. + if (requestSeq !== s3tablesTablePolicyRequestSeq) return; + if (loadError) { + console.error('Failed to load table policy', loadError); + alert('Failed to load table policy: ' + loadError.message + '. Close and reopen this dialog to try again.'); + return; + } + document.getElementById('s3tablesTablePolicyText').value = policyText; } + if (requestSeq !== s3tablesTablePolicyRequestSeq) return; + s3tablesTablePolicyLoaded = true; + loadPolicyTextareaIntoEditor('s3tablesTable'); } async function deleteS3TablesTablePolicy() { + if (!s3tablesTablePolicyLoaded) { + alert('The current policy has not finished loading. Close and reopen this dialog before deleting.'); + return; + } const dataContainer = document.getElementById('s3tables-tables-content'); const dataBucketArn = dataContainer.dataset.bucketArn || ''; const dataNamespace = dataContainer.dataset.namespace || ''; @@ -705,6 +803,7 @@ async function deleteS3TablesTablePolicy() { } alert('Policy deleted'); document.getElementById('s3tablesTablePolicyText').value = ''; + commitPolicyTextareaToEditor('s3tablesTable'); } catch (error) { alert('Failed to delete policy: ' + error.message); } diff --git a/weed/admin/static_gz/js/policy_editor.js.gz b/weed/admin/static_gz/js/policy_editor.js.gz new file mode 100644 index 000000000..5a7183cc2 Binary files /dev/null and b/weed/admin/static_gz/js/policy_editor.js.gz differ diff --git a/weed/admin/static_gz/js/s3tables.js.gz b/weed/admin/static_gz/js/s3tables.js.gz index c97a2aca9..2b5cd7e1f 100644 Binary files a/weed/admin/static_gz/js/s3tables.js.gz and b/weed/admin/static_gz/js/s3tables.js.gz differ diff --git a/weed/admin/view/app/policies.templ b/weed/admin/view/app/policies.templ index ac529054f..dd85f75c2 100644 --- a/weed/admin/view/app/policies.templ +++ b/weed/admin/view/app/policies.templ @@ -198,22 +198,7 @@ templ Policies(data dash.PoliciesData) { } - - - for _, action := range PolicyActionSuggestions { - - } - - - - - - - + @PolicyDatalists() ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") 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 = PolicyDatalists().Render(ctx, templ_7745c5c3_Buffer) + 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
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
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_datalists.templ b/weed/admin/view/app/policy_datalists.templ new file mode 100644 index 000000000..53a916503 --- /dev/null +++ b/weed/admin/view/app/policy_datalists.templ @@ -0,0 +1,28 @@ +package app + +// PolicyDatalists renders the three elements the shared visual +// policy editor (weed/admin/static/js/policy_editor.js) attaches its +// action/resource/principal suggestions to. Any page +// embedding that editor must render this once; its ids +// (policyActionSuggestions, policyResourceSuggestions, +// policyPrincipalSuggestions) are the registerPolicyEditor defaults, so a +// page only needs to override them if it renders more than one instance of +// this component. +templ PolicyDatalists() { + + + for _, action := range PolicyActionSuggestions { + + } + + + + + + + +} diff --git a/weed/admin/view/app/policy_datalists_templ.go b/weed/admin/view/app/policy_datalists_templ.go new file mode 100644 index 000000000..0116f6845 --- /dev/null +++ b/weed/admin/view/app/policy_datalists_templ.go @@ -0,0 +1,71 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package app + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// PolicyDatalists renders the three elements the shared visual +// policy editor (weed/admin/static/js/policy_editor.js) attaches its +// action/resource/principal suggestions to. Any page +// embedding that editor must render this once; its ids +// (policyActionSuggestions, policyResourceSuggestions, +// policyPrincipalSuggestions) are the registerPolicyEditor defaults, so a +// page only needs to override them if it renders more than one instance of +// this component. +func PolicyDatalists() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, action := range PolicyActionSuggestions { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ index 47714f1bd..fe124cd3a 100644 --- a/weed/admin/view/app/s3_buckets.templ +++ b/weed/admin/view/app/s3_buckets.templ @@ -151,6 +151,7 @@ templ S3Buckets(data dash.S3BucketsData) { Versioning Object Lock Lifecycle + Policy Actions @@ -255,6 +256,21 @@ templ S3Buckets(data dash.S3BucketsData) { Not configured } + + if bucket.PolicyStatementCount > 0 { + + } else { + Not configured + } + + @PolicyDatalists() + + + +
Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 386, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 408, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Lifecycle
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Lifecycle
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PolicyDatalists().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
Bucket Policy
Loading...
Loading bucket policy...

Every statement must specify a Principal, and every Resource must refer to this bucket. Leave the document empty and click Delete to remove the policy.

Enter the bucket policy document as JSON
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/s3tables_buckets.templ b/weed/admin/view/app/s3tables_buckets.templ index b6458e41d..9530175d0 100644 --- a/weed/admin/view/app/s3tables_buckets.templ +++ b/weed/admin/view/app/s3tables_buckets.templ @@ -150,6 +150,7 @@ templ S3TablesBuckets(data dash.S3TablesBucketsData) { ARN Catalog Endpoint Created + Policy Actions @@ -164,6 +165,20 @@ templ S3TablesBuckets(data dash.S3TablesBucketsData) { { bucket.ARN } { bucketCatalogPath(bucket.Format, bucket.Name) } { bucket.CreatedAt.Format("2006-01-02 15:04") } + + if bucket.PolicyStatementCount > 0 { + + } else { + Not configured + } +
{{ bucketName, parseErr := s3tables.ParseBucketNameFromARN(bucket.ARN) }} @@ -191,7 +206,7 @@ templ S3TablesBuckets(data dash.S3TablesBucketsData) { } if len(data.Buckets) == 0 { - +
No table buckets found
@@ -395,8 +410,10 @@ dataset = lance.dataset(table.location, storage_options=table.storage_options)`
+ @PolicyDatalists() + ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.IcebergPort > 0 || data.LancePort > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
Client Examples
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
Client Examples
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.IcebergPort > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
ICEBERGDuckDB
")
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
ICEBERGDuckDB
")
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				var templ_7745c5c3_Var27 string
-				templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(`INSTALL iceberg;
+				var templ_7745c5c3_Var29 string
+				templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(`INSTALL iceberg;
 LOAD iceberg;
 
 CREATE SECRET (
@@ -471,18 +519,18 @@ CREATE SECRET (
 
 SELECT * FROM iceberg_scan('s3://my-table-bucket/my-namespace/my-table');`)
 				if templ_7745c5c3_Err != nil {
-					return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_buckets.templ`, Line: 236, Col: 74}
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3tables_buckets.templ`, Line: 251, Col: 74}
 				}
-				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
ICEBERGPython (PyIceberg)
")
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
ICEBERGPython (PyIceberg)
")
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				var templ_7745c5c3_Var28 string
-				templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(`from pyiceberg.catalog import load_catalog
+				var templ_7745c5c3_Var30 string
+				templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(`from pyiceberg.catalog import load_catalog
 
 catalog = load_catalog(
     name="seaweedfs",
@@ -495,60 +543,60 @@ catalog = load_catalog(
 
 namespaces = catalog.list_namespaces()`)
 				if templ_7745c5c3_Err != nil {
-					return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_buckets.templ`, Line: 253, Col: 39}
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3tables_buckets.templ`, Line: 268, Col: 39}
 				}
-				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if data.LancePort > 0 { - var templ_7745c5c3_Var29 = []any{templ.KV("mt-4", data.IcebergPort > 0)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...) + var templ_7745c5c3_Var31 = []any{templ.KV("mt-4", data.IcebergPort > 0)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
LANCEPython (lance-namespace)
")
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">LANCEPython (lance-namespace)
")
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				var templ_7745c5c3_Var31 string
-				templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(`from lance_namespace import connect
+				var templ_7745c5c3_Var33 string
+				templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(`from lance_namespace import connect
 
 ns = connect("rest", {"uri": "http://localhost:` + fmt.Sprintf("%d", data.LancePort) + `"})
 
 ns.list_namespaces(id=["my-table-bucket"])
 ns.describe_table(id=["my-table-bucket", "my-namespace", "my-table"])`)
 				if templ_7745c5c3_Err != nil {
-					return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_buckets.templ`, Line: 266, Col: 70}
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3tables_buckets.templ`, Line: 281, Col: 70}
 				}
-				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
LANCEPython (pylance)
")
+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
LANCEPython (pylance)
")
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				var templ_7745c5c3_Var32 string
-				templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(`import lance
+				var templ_7745c5c3_Var34 string
+				templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(`import lance
 from lance_namespace import connect
 
 ns = connect("rest", {"uri": "http://localhost:` + fmt.Sprintf("%d", data.LancePort) + `"})
@@ -557,84 +605,92 @@ table = ns.describe_table(id=["my-table-bucket", "my-namespace", "my-table"])
 # The namespace vends both the location and the credentials to read it.
 dataset = lance.dataset(table.location, storage_options=table.storage_options)`)
 				if templ_7745c5c3_Err != nil {
-					return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3tables_buckets.templ`, Line: 279, Col: 79}
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3tables_buckets.templ`, Line: 294, Col: 79}
 				}
-				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
 				if templ_7745c5c3_Err != nil {
 					return templ_7745c5c3_Err
 				}
-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
Create Table Bucket
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
Create Table Bucket
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.IcebergPort > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.LancePort > 0 && data.IcebergPort <= 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if data.LancePort > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
Lance Served over the Lance Namespace API. pylance, LanceDB, Ray.
A bucket holds one format. Tables of the other are refused.
The S3 identity that owns this table bucket. Non-admin users can only access table buckets they own.
Optional tags in key=value format.
Delete Table Bucket

Are you sure you want to delete the table bucket ?

Table Bucket Policy
Provide a policy JSON; use Delete Policy to remove the policy.
Resource Tags
Loading...
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">A bucket holds one format. Tables of the other are refused.
The S3 identity that owns this table bucket. Non-admin users can only access table buckets they own.
Optional tags in key=value format.
Delete Table Bucket

Are you sure you want to delete the table bucket ?

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PolicyDatalists().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
Table Bucket Policy
Provide a policy JSON; use Delete Policy to remove the policy.
Resource Tags
Loading...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/s3tables_tables.templ b/weed/admin/view/app/s3tables_tables.templ index 84ce15481..8df58785d 100644 --- a/weed/admin/view/app/s3tables_tables.templ +++ b/weed/admin/view/app/s3tables_tables.templ @@ -254,8 +254,10 @@ templ S3TablesTables(data dash.S3TablesTablesData) { + @PolicyDatalists() +
Delete Table

Are you sure you want to delete the table ?

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PolicyDatalists().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
Table Policy
Resource Tags
Loading...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index 9733919de..09fc8f0cf 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -370,6 +370,7 @@ templ Layout(view ViewContext, content templ.Component) { + } diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go index 5c7fa667f..bd1ad4cf8 100644 --- a/weed/admin/view/layout/layout_templ.go +++ b/weed/admin/view/layout/layout_templ.go @@ -85,7 +85,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 55, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 55, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -98,7 +98,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(string(view.P("/static/favicon.ico"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 56, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 56, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -111,7 +111,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var4 templ.SafeURL templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(string(view.P("/static/css/bootstrap.min.css"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 59, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 59, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -124,7 +124,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var5 templ.SafeURL templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(string(view.P("/static/css/fontawesome.min.css"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 61, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 61, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -137,7 +137,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(view.P("/static/js/htmx.min.js"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 63, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 63, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -150,7 +150,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(string(view.P("/static/css/admin.css"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 65, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 65, Col: 73} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -171,7 +171,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var8 templ.SafeURL templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/admin")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 74, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 74, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -184,7 +184,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 90, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 90, Col: 73} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -197,7 +197,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var10 templ.SafeURL templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/logout")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 93, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 93, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -210,7 +210,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var11 templ.SafeURL templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/admin")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 112, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 112, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -232,7 +232,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -245,7 +245,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", isClusterPage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 117, Col: 207} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 117, Col: 207} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -267,7 +267,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -280,7 +280,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var17 templ.SafeURL templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/cluster/masters")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 124, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 124, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -293,7 +293,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var18 templ.SafeURL templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/cluster/volume-servers")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 129, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 129, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -306,7 +306,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var19 templ.SafeURL templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/cluster/filers")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 134, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 134, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -319,7 +319,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var20 templ.SafeURL templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/cluster/s3")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 139, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 139, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -332,7 +332,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var21 templ.SafeURL templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/cluster/mount-clients")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 144, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 144, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -354,7 +354,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var22).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) if templ_7745c5c3_Err != nil { @@ -367,7 +367,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", isStoragePage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 152, Col: 207} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 152, Col: 207} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) if templ_7745c5c3_Err != nil { @@ -389,7 +389,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) if templ_7745c5c3_Err != nil { @@ -402,7 +402,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var27 templ.SafeURL templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/storage/volumes")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 159, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 159, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -415,7 +415,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var28 templ.SafeURL templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/storage/ec-shards")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 164, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 164, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -428,7 +428,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var29 templ.SafeURL templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/storage/collections")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 169, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 169, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -441,7 +441,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var30 templ.SafeURL templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/object-store/buckets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 183, Col: 86} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 183, Col: 86} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { @@ -454,7 +454,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var31 templ.SafeURL templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/object-store/s3tables/buckets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 188, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 188, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -467,7 +467,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var32 templ.SafeURL templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/object-store/users")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 193, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 193, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { @@ -480,7 +480,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var33 templ.SafeURL templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/object-store/groups")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 198, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 198, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { @@ -493,7 +493,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var34 templ.SafeURL templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/object-store/service-accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 203, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 203, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -506,7 +506,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var35 templ.SafeURL templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/object-store/policies")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 208, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 208, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { @@ -519,7 +519,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var36 templ.SafeURL templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/files")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 219, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 219, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { @@ -553,7 +553,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var37 templ.SafeURL templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/mq/brokers")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 240, Col: 108} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 240, Col: 108} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { @@ -571,7 +571,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var38 templ.SafeURL templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/mq/brokers")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 244, Col: 101} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 244, Col: 101} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { @@ -594,7 +594,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var39 templ.SafeURL templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/mq/topics")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 251, Col: 107} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 251, Col: 107} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { @@ -612,7 +612,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var40 templ.SafeURL templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/mq/topics")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 255, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 255, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { @@ -635,7 +635,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var41 templ.SafeURL templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/mq/brokers")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 267, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 267, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { @@ -648,7 +648,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var42 templ.SafeURL templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/mq/topics")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 272, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 272, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { @@ -671,7 +671,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var43 templ.SafeURL templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/default")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 289, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 289, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { @@ -689,7 +689,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var44 templ.SafeURL templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/default")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 293, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 293, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { @@ -712,7 +712,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var45 templ.SafeURL templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/lifecycle")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 300, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 300, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { @@ -730,7 +730,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var46 templ.SafeURL templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/lifecycle")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 304, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 304, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { @@ -753,7 +753,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var47 templ.SafeURL templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/iceberg")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 311, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 311, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { @@ -771,7 +771,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var48 templ.SafeURL templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/iceberg")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 315, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 315, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { @@ -794,7 +794,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var49 templ.SafeURL templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/lance")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 322, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 322, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { @@ -812,7 +812,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var50 templ.SafeURL templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinURLErrs(view.P("/plugin/lanes/lance")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 326, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 326, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) if templ_7745c5c3_Err != nil { @@ -838,7 +838,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var51 string templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 351, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 351, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) if templ_7745c5c3_Err != nil { @@ -851,7 +851,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var52 string templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 351, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 351, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) if templ_7745c5c3_Err != nil { @@ -869,7 +869,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var53 string templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(version.COMMIT) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 353, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 353, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) if templ_7745c5c3_Err != nil { @@ -893,7 +893,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var54 string templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(view.P("/static/js/bootstrap.bundle.min.js"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 366, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 366, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) if templ_7745c5c3_Err != nil { @@ -906,7 +906,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var55 string templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(view.P("/static/js/modal-alerts.js"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 368, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 368, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55) if templ_7745c5c3_Err != nil { @@ -919,7 +919,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var56 string templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(view.P("/static/js/admin.js"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 370, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 370, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) if templ_7745c5c3_Err != nil { @@ -932,7 +932,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var57 string templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(view.P("/static/js/iam-utils.js"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 371, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 371, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57) if templ_7745c5c3_Err != nil { @@ -945,13 +945,26 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var58 string templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(view.P("/static/js/s3tables.js"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 372, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 372, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -975,140 +988,140 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var59 := templ.GetChildren(ctx) - if templ_7745c5c3_Var59 == nil { - templ_7745c5c3_Var59 = templ.NopComponent + templ_7745c5c3_Var60 := templ.GetChildren(ctx) + if templ_7745c5c3_Var60 == nil { + templ_7745c5c3_Var60 = templ.NopComponent } ctx = templ.ClearChildren(ctx) prefix := dash.URLPrefixFromContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var60 string - templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(title) + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 17} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " - Login

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" rel=\"stylesheet\">

Please sign in to continue

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" rel=\"stylesheet\">

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 400, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

Please sign in to continue

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var65 string - templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 406, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 407, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/s3api/policy_engine/bucket_policy.go b/weed/s3api/policy_engine/bucket_policy.go new file mode 100644 index 000000000..b7cf969c9 --- /dev/null +++ b/weed/s3api/policy_engine/bucket_policy.go @@ -0,0 +1,86 @@ +package policy_engine + +import ( + "fmt" + "strings" +) + +// ValidateBucketPolicy performs bucket-specific policy validation, on top of +// the generic structural checks in ValidatePolicy. It enforces the rules +// that make a policy document valid as an S3 *bucket* policy specifically: +// every statement must name a Principal, and every Resource/NotResource/Action +// must scope to the given bucket. +// +// This is shared between the S3 gateway's PutBucketPolicy handler +// (weed/s3api/s3api_bucket_policy_handlers.go) and the admin UI +// (weed/admin/dash) so both enforce identical rules. +func ValidateBucketPolicy(policyDoc *PolicyDocument, bucket string) error { + if policyDoc.Version != PolicyVersion2012_10_17 { + return fmt.Errorf("unsupported policy version: %s (must be %s)", policyDoc.Version, PolicyVersion2012_10_17) + } + + if len(policyDoc.Statement) == 0 { + return fmt.Errorf("policy document must contain at least one statement") + } + + for i, statement := range policyDoc.Statement { + // Bucket policies must have Principal + if statement.Principal == nil { + return fmt.Errorf("statement %d: bucket policies must specify a Principal", i) + } + + // Validate resources refer to this bucket + for _, resource := range statement.Resource.Strings() { + if !ResourceMatchesBucket(resource, bucket) { + return fmt.Errorf("statement %d: resource %s does not match bucket %s", i, resource, bucket) + } + } + + // Validate NotResources refer to this bucket + if statement.NotResource != nil { + for _, notResource := range statement.NotResource.Strings() { + if !ResourceMatchesBucket(notResource, bucket) { + return fmt.Errorf("statement %d: NotResource %s does not match bucket %s", i, notResource, bucket) + } + } + } + + // Validate actions are S3 actions + for _, action := range statement.Action.Strings() { + if !strings.HasPrefix(action, "s3:") { + return fmt.Errorf("statement %d: bucket policies only support S3 actions, got %s", i, action) + } + } + } + + return nil +} + +// ResourceMatchesBucket checks if a resource ARN is valid for the given bucket. +func ResourceMatchesBucket(resource, bucket string) bool { + // Accepted formats for S3 bucket policies: + // AWS-style ARNs (standard): + // arn:aws:s3:::bucket-name + // arn:aws:s3:::bucket-name/* + // arn:aws:s3:::bucket-name/path/to/object + // Simplified formats (for convenience): + // bucket-name + // bucket-name/* + // bucket-name/path/to/object + + var resourcePath string + const awsPrefix = "arn:aws:s3:::" + + // Strip the optional ARN prefix to get the resource path + if path, ok := strings.CutPrefix(resource, awsPrefix); ok { + resourcePath = path + } else { + resourcePath = resource + } + + // After stripping the optional ARN prefix, the resource path must + // either match the bucket name exactly, or be a path within the bucket. + return resourcePath == bucket || + resourcePath == bucket+"/*" || + strings.HasPrefix(resourcePath, bucket+"/") +} diff --git a/weed/s3api/policy_engine/bucket_policy_test.go b/weed/s3api/policy_engine/bucket_policy_test.go new file mode 100644 index 000000000..217e1c8fa --- /dev/null +++ b/weed/s3api/policy_engine/bucket_policy_test.go @@ -0,0 +1,106 @@ +package policy_engine + +import "testing" + +func TestResourceMatchesBucket(t *testing.T) { + tests := []struct { + name string + resource string + bucket string + want bool + }{ + {"bare bucket name", "my-bucket", "my-bucket", true}, + {"bare wildcard", "my-bucket/*", "my-bucket", true}, + {"bare object key", "my-bucket/path/to/key", "my-bucket", true}, + {"arn bucket", "arn:aws:s3:::my-bucket", "my-bucket", true}, + {"arn wildcard", "arn:aws:s3:::my-bucket/*", "my-bucket", true}, + {"arn object key", "arn:aws:s3:::my-bucket/path/to/key", "my-bucket", true}, + {"wrong bucket", "other-bucket", "my-bucket", false}, + {"wrong bucket arn", "arn:aws:s3:::other-bucket/*", "my-bucket", false}, + {"prefix collision", "my-bucket2", "my-bucket", false}, + {"prefix collision arn", "arn:aws:s3:::my-bucket2/*", "my-bucket", false}, + {"empty resource", "", "my-bucket", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResourceMatchesBucket(tt.resource, tt.bucket); got != tt.want { + t.Errorf("ResourceMatchesBucket(%q, %q) = %v, want %v", tt.resource, tt.bucket, got, tt.want) + } + }) + } +} + +func simpleAwsPrincipal() *PolicyPrincipal { + return NewPolicyPrincipalPtr("*") +} + +func TestValidateBucketPolicy(t *testing.T) { + bucket := "my-bucket" + + validStatement := func() PolicyStatement { + return PolicyStatement{ + Effect: PolicyEffectAllow, + Principal: simpleAwsPrincipal(), + Action: NewStringOrStringSlice("s3:GetObject"), + Resource: NewStringOrStringSlicePtr("arn:aws:s3:::" + bucket + "/*"), + } + } + + t.Run("valid policy", func(t *testing.T) { + doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{validStatement()}} + if err := ValidateBucketPolicy(doc, bucket); err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("bad version", func(t *testing.T) { + doc := &PolicyDocument{Version: "2008-10-17", Statement: []PolicyStatement{validStatement()}} + if err := ValidateBucketPolicy(doc, bucket); err == nil { + t.Error("expected error for bad version") + } + }) + + t.Run("zero statements", func(t *testing.T) { + doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{}} + if err := ValidateBucketPolicy(doc, bucket); err == nil { + t.Error("expected error for zero statements") + } + }) + + t.Run("missing principal", func(t *testing.T) { + stmt := validStatement() + stmt.Principal = nil + doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}} + if err := ValidateBucketPolicy(doc, bucket); err == nil { + t.Error("expected error for missing principal") + } + }) + + t.Run("foreign resource", func(t *testing.T) { + stmt := validStatement() + stmt.Resource = NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*") + doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}} + if err := ValidateBucketPolicy(doc, bucket); err == nil { + t.Error("expected error for foreign resource") + } + }) + + t.Run("foreign not-resource", func(t *testing.T) { + stmt := validStatement() + stmt.Resource = nil + stmt.NotResource = NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*") + doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}} + if err := ValidateBucketPolicy(doc, bucket); err == nil { + t.Error("expected error for foreign NotResource") + } + }) + + t.Run("non-s3 action", func(t *testing.T) { + stmt := validStatement() + stmt.Action = NewStringOrStringSlice("iam:CreateUser") + doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}} + if err := ValidateBucketPolicy(doc, bucket); err == nil { + t.Error("expected error for non-s3 action") + } + }) +} diff --git a/weed/s3api/s3api_bucket_policy_handlers.go b/weed/s3api/s3api_bucket_policy_handlers.go index f8cdea153..9e38fe09f 100644 --- a/weed/s3api/s3api_bucket_policy_handlers.go +++ b/weed/s3api/s3api_bucket_policy_handlers.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "strings" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" @@ -16,7 +15,10 @@ import ( "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) -// Bucket policy metadata key for storing policies in filer +// Bucket policy metadata key for storing policies in filer. +// Also consumed directly by weed/admin/dash for the admin UI's bucket +// policy management, so keep it exported and don't change its value +// without updating that package too. const BUCKET_POLICY_METADATA_KEY = "s3-bucket-policy" // Sentinel errors for bucket policy operations @@ -97,7 +99,7 @@ func (s3a *S3ApiServer) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Re } // Additional bucket policy specific validation - if err := s3a.validateBucketPolicy(&policyDoc, bucket); err != nil { + if err := policy_engine.ValidateBucketPolicy(&policyDoc, bucket); err != nil { glog.Errorf("Bucket policy validation failed: %v", err) s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPolicyDocument) return @@ -293,78 +295,6 @@ func (s3a *S3ApiServer) deleteBucketPolicy(bucket string) error { }) } -// validateBucketPolicy performs bucket-specific policy validation -func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy_engine.PolicyDocument, bucket string) error { - if policyDoc.Version != "2012-10-17" { - return fmt.Errorf("unsupported policy version: %s (must be 2012-10-17)", policyDoc.Version) - } - - if len(policyDoc.Statement) == 0 { - return fmt.Errorf("policy document must contain at least one statement") - } - - for i, statement := range policyDoc.Statement { - // Bucket policies must have Principal - if statement.Principal == nil { - return fmt.Errorf("statement %d: bucket policies must specify a Principal", i) - } - - // Validate resources refer to this bucket - for _, resource := range statement.Resource.Strings() { - if !s3a.validateResourceForBucket(resource, bucket) { - return fmt.Errorf("statement %d: resource %s does not match bucket %s", i, resource, bucket) - } - } - - // Validate NotResources refer to this bucket - if statement.NotResource != nil { - for _, notResource := range statement.NotResource.Strings() { - if !s3a.validateResourceForBucket(notResource, bucket) { - return fmt.Errorf("statement %d: NotResource %s does not match bucket %s", i, notResource, bucket) - } - } - } - - // Validate actions are S3 actions - for _, action := range statement.Action.Strings() { - if !strings.HasPrefix(action, "s3:") { - return fmt.Errorf("statement %d: bucket policies only support S3 actions, got %s", i, action) - } - } - } - - return nil -} - -// validateResourceForBucket checks if a resource ARN is valid for the given bucket -func (s3a *S3ApiServer) validateResourceForBucket(resource, bucket string) bool { - // Accepted formats for S3 bucket policies: - // AWS-style ARNs (standard): - // arn:aws:s3:::bucket-name - // arn:aws:s3:::bucket-name/* - // arn:aws:s3:::bucket-name/path/to/object - // Simplified formats (for convenience): - // bucket-name - // bucket-name/* - // bucket-name/path/to/object - - var resourcePath string - const awsPrefix = "arn:aws:s3:::" - - // Strip the optional ARN prefix to get the resource path - if path, ok := strings.CutPrefix(resource, awsPrefix); ok { - resourcePath = path - } else { - resourcePath = resource - } - - // After stripping the optional ARN prefix, the resource path must - // either match the bucket name exactly, or be a path within the bucket. - return resourcePath == bucket || - resourcePath == bucket+"/*" || - strings.HasPrefix(resourcePath, bucket+"/") -} - // IAM integration functions // updateBucketPolicyInIAM updates the IAM system with the new bucket policy