mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-28 03:46:24 +00:00
Manage bucket policies via the admin ui (#10895)
* admin: manage S3 bucket policies from the admin UI
Bucket policies were only manageable through the S3 PutBucketPolicy API;
the admin UI had no equivalent to the quota/owner/lifecycle editors it
already offers. Add GET/PUT/DELETE for a bucket's policy, sharing the
exact validation the S3 gateway uses.
- Extract validateBucketPolicy/validateResourceForBucket out of
s3api_bucket_policy_handlers.go into policy_engine.ValidateBucketPolicy /
ResourceMatchesBucket so both the S3 API and the admin UI enforce
identical rules.
- weed/admin/dash/bucket_policy.go: Get/Set/DeleteBucketPolicy, writing
through ObjectTransaction + PATCH_EXTENDED (the lifecycle pattern) so a
concurrent owner/quota/lifecycle change on the same bucket entry isn't
clobbered. Propagation to every S3 gateway is automatic via the existing
filer metadata log subscription. The S3 gateway's IAM policy mirror is
deliberately not replicated here (its delete path is already an
unimplemented TODO on the S3 side).
- New GET/PUT/DELETE /api/s3/buckets/{bucket}/policy routes, CSRF-guarded
on writes.
- Bucket list and details modal now show a statement-count badge, read
from the entry already fetched (no extra RPC).
- UI: a JSON-textarea policy editor modal, matching the lifecycle modal's
structure.
* admin: reuse the visual policy editor for bucket policies
Extract the structured policy editor (add/remove statement, action/
resource/principal rows with autocomplete, JSON tab kept in sync) out of
policies.templ's inline script into a shared
weed/admin/static/js/policy_editor.js, and wire the bucket policy modal
in s3_buckets.templ up to it instead of a bare JSON textarea.
- registerPolicyEditor(which, config) replaces the hardcoded create/edit
id derivation with a per-instance config (textarea/tab/body ids,
datalist ids, requirePrincipal, bucket). The IAM policies page keeps its
exact pre-extraction ids via two registerPolicyEditor calls, so its
markup is unchanged.
- New policy_datalists.templ exposes the three shared <datalist>s
(actions/resources/principals) as @PolicyDatalists(), now rendered by
both policies.templ and s3_buckets.templ.
- requirePrincipal seeds new bucket-policy statements with Principal: "*"
and adds a client-side check before save (the server, via
policy_engine.ValidateBucketPolicy, remains the actual authority); the
bucket config pins the Resource autocomplete to the open bucket instead
of fetching every bucket in the cluster.
- layout.templ loads policy_editor.js globally, after admin.js/
modal-alerts.js (basePath/escapeHtml/showAlert) which it depends on.
3a (the extraction) is a byte-preserving move verified against the
unchanged policies.templ behavior before layering 3b's parameterization
and the bucket-policy wiring on top.
* admin: migrate S3 Tables bucket/table policy editors to the shared editor
Third consumer of the shared visual policy editor: the S3 Tables bucket
and table policy modals (a bare JSON textarea each) now get the same
structured Editor/JSON tabs as the bucket policy and IAM policy pages,
via registerPolicyEditor('s3tablesBucketPolicy'/'s3tablesTablePolicy',
{ textareaId: ... }). Storage and validation are untouched - S3 Tables
policies still go through their own s3tables.PolicyDocument type and the
s3tables.policy extended attribute, unrelated to policy_engine and
s3-bucket-policy; only the editor UI is shared.
Fix a real bug surfaced by adding this second load path: the bucket
policy modal (and the naive first draft of this s3tables port) called
commitPolicyTextareaToEditor() right after a GET and then force-switched
to the Editor tab. commitPolicyTextareaToEditor() is designed to leave
the current tab in place and the editor state untouched when a document
fails to parse (so an in-progress edit survives a bad tab switch), so
forcing the Editor tab afterwards could show empty/stale editor state
that a careless Save would then serialize over a perfectly valid but
structurally-unusual stored policy. Add
loadPolicyTextareaIntoEditor(which) to policy_editor.js, which has no
"current tab" to defer to and instead falls back to the JSON tab with an
alert on a document the structured editor can't represent - the same
safety editPolicy already had in policies.templ - and use it at all three
"populate the editor right after a GET" call sites (bucket policy,
S3 Tables bucket policy, S3 Tables table policy).
* admin: show policy statement count on the S3 Tables buckets page
Mirrors the "Policy" column already added to the classic S3 buckets
list: a clickable badge with the statement count when the table bucket
has a resource policy, "Not configured" otherwise. S3 Tables policies
are a separate mechanism (s3tables.PolicyDocument under the
s3tables.policy extended attribute) from the S3 bucket policy work
elsewhere in this branch (policy_engine.PolicyDocument /
s3-bucket-policy), so this is a parallel implementation of the same
pattern rather than shared code.
- S3TablesBucketSummary gains PolicyStatementCount, populated in
GetS3TablesBucketsData from entry.Entry.Extended[s3tables.ExtendedKeyPolicy]
via the new extractS3TablesPolicyStatementCountFromEntry - no extra RPC,
the entry is already fetched for ExtendedKeyMetadata.
- The badge reuses the existing .s3tables-bucket-policy-btn class, so it
opens the same policy modal as the row's action button with no JS
changes.
* admin: don't let a failed policy GET open the door to an empty overwrite
loadS3TablesBucketPolicy/loadS3TablesTablePolicy cleared the textarea,
then unconditionally called loadPolicyTextareaIntoEditor() regardless of
whether the GET actually succeeded - including when fetch() rejected or
the response was not ok, silently logged to console only. That leaves
the structured editor holding a legitimate-looking empty policy
({version, statements: []}), with the Editor tab active by default.
If Save is then clicked, commitPolicyActiveTab() serializes that empty
state into the textarea as `{"Version":"2012-10-17","Statement":[]}` -
a non-empty string - before the "Policy JSON is required" guard ever
sees it, so the guard passes and the transient load failure gets
written over whatever policy was actually stored.
Add s3tablesBucketPolicyLoaded/s3tablesTablePolicyLoaded, set true only
once a GET has actually completed (ok, including a genuinely empty
policy) and false on any failure path (fetch rejection or a non-ok
response, which previously fell through silently). Both submit handlers
now check the flag before touching the editor at all, and a failed load
surfaces via alert() instead of only a console.error - the user
previously had no visible indication the load had failed.
Verified with a jsdom simulation driving the real rendered page against
a stubbed fetch: a failed GET followed by Save now sends no PUT at all
(previously it sent Statement: []); a successful GET followed by Save
still PUTs the loaded policy unchanged.
* admin: address code review findings on the policy editor
1. policy_editor.js: policyEditors is only pre-populated for 'create'/
'edit'; every other `which` (bucket, s3tablesBucket, s3tablesTable)
stays undefined until its first successful async load. Nothing in
this file enforces that a page hide its Editor/JSON tabs and
Add-statement button until that load completes - the S3 Tables policy
modals don't - so a click in that window (e.g. Add statement, or
switching to the JSON tab) threw "Cannot read properties of undefined
(reading 'unparsed')". Add policyEditorState(which), which lazily
initializes a default state, and route addPolicyStatement, the
jsonTabBtn 'show.bs.tab' handler, commitPolicyActiveTab, and
renderPolicyEditor through it. Verified with a jsdom simulation
against a never-resolving fetch: the exact click threw on the
pre-fix code and no longer does.
2. s3_buckets.templ: the bucket-policy Save handler checked the
textarea for emptiness before calling commitPolicyActiveTab(), which
is what actually serializes the structured Editor tab's fields into
that textarea. A policy entered entirely through the Editor tab (the
primary path - never touching the JSON tab) left the textarea at
whatever it was at load time, so creating a new policy this way hit
"Enter a policy document" and Save silently did nothing. Move the
commit before the emptiness check, preserving the existing alert and
early-return. Verified with a jsdom simulation: Add-statement then
Save (no tab switch) now PUTs the entered statement; before the fix
the same sequence never reached fetch().
3. s3tables_buckets.templ / s3tables_tables.templ: the policy Editor/
JSON nav-tabs were missing the ARIA roles Bootstrap's own tab pattern
expects (role="tab"/"tabpanel", aria-selected, aria-controls,
aria-labelledby) - screen readers had no way to tell these were tabs
or which pane went with which button. Added the standard Bootstrap 5
tab markup to both.
* admin: guard policy load/save flows against overlapping requests
1. s3tables.js: loadS3TablesBucketPolicy/loadS3TablesTablePolicy had no
protection against overlapping loads. Opening one bucket's (or
table's) policy dialog and then another's before the first GET
resolved let the late response write its document into the shared
textarea and mark the dialog "loaded" while it was now targeting the
second resource - a subsequent Save would then push the first
resource's policy onto the second. Add a per-load monotonic sequence
number (s3tablesBucketPolicyRequestSeq / s3tablesTablePolicyRequestSeq,
the same pattern already used for the classic bucket-policy load in
s3_buckets.templ); a response is only applied - textarea, loaded flag,
editor state - if its captured sequence still matches the latest one
issued.
Verified with a jsdom simulation: bucket A's policy load (artificially
slow) followed immediately by bucket B's (fast) previously left A's
policy in the textarea once A's late response landed; it now correctly
keeps B's.
2. s3_buckets.templ: the bucket-policy Save button lives outside the
(initially hidden) editor wrapper, so it stays clickable while a load
is still in flight - the existing policyRequestSeq guard only protects
the *load* from a stale response, not Save from firing before any
load for the current bucket has completed. Add bucketPolicyLoaded,
reset before each GET and set only once the matching response lands,
and check it at the top of the Save handler.
Verified with a jsdom simulation: clicking Save immediately after
opening the dialog, before a (deliberately never-resolving) GET
settles, now sends no PUT; a normal load-then-save sequence still
PUTs the loaded policy unchanged.
* admin: address further code review findings on the policy editor
1. s3tables.js: loadS3TablesBucketPolicy/loadS3TablesTablePolicy only
reset the JSON textarea when a new load starts; the structured editor
kept showing the previously loaded resource's statements (Editor tab
is the default active one) until the new fetch resolved. Call
loadPolicyTextareaIntoEditor() against the now-cleared textarea
immediately, so switching resources visibly resets the editor right
away instead of only once its own load completes. Verified with jsdom:
opening bucket A (loads fully) then bucket B (GET never resolves) no
longer leaves A's statements visible in B's editor.
2. s3tables.js: deleteS3TablesBucketPolicy/deleteS3TablesTablePolicy had
no loaded-state check, so a failed GET (which already blocks Save)
left Delete fully able to remove the resource's stored policy sight
unseen. Add the same s3tablesBucketPolicyLoaded/s3tablesTablePolicyLoaded
guard Save already uses. Verified with jsdom: delete after a failed
load now sends no DELETE; delete after a successful load is unaffected.
3. s3_buckets.templ: the bucket-policy Editor/JSON nav-tabs were missing
the same ARIA roles already added to the S3 Tables policy tabs in an
earlier round (role="tab"/"tabpanel", aria-selected, aria-controls,
aria-labelledby) - this instance was out of scope for that review
comment but is the same gap. Bootstrap's own tab.js already manages
aria-selected on tab switch once the attribute exists, so no extra JS
was needed.
4. s3_buckets.templ: neither the bucket-policy Save nor Delete handler
guarded against a double-click, or against firing while the other was
still in flight - two overlapping PUT/DELETE requests for the same
bucket could land in either order. Add a shared
bucketPolicyMutationInFlight flag: set (and both buttons disabled)
before each fetch, cleared (and buttons re-enabled) on failure so the
user can retry, left set through the existing success hide-and-reload
path, and also reset when a new bucket's dialog opens so an abandoned
in-flight request from a closed dialog can't leave the buttons stuck
disabled. Verified with jsdom: double-clicking Save now sends exactly
one PUT, and a Delete click while that PUT is still pending sends no
DELETE.
* admin: scope bucket-policy mutation completions to the bucket that started them
1. The previous round's fix reset bucketPolicyMutationInFlight whenever a
new bucket's policy dialog opened, to avoid leaving Save/Delete stuck
disabled if the modal was closed mid-request. That traded one bug for
a worse one: if bucket A's PUT/DELETE was still in flight when the
user opened bucket B's dialog, the reset let B's Save/Delete fire
immediately, and A's completion handler - unaware anything had
changed - would still hide the (now B's) modal and reload the page
out from under whatever the user was doing with B, on success, or
alert a message with no bucket context, on failure.
Stop resetting on reopen, so a pending mutation for a previous bucket
keeps this bucket's Save/Delete blocked until it settles (matches the
"preventing overlapping mutations" the review comment describes).
Instead, capture policyEditorBucket as targetBucket right before each
fetch and compare it against policyEditorBucket again in the
completion handler: the in-flight flag is always released so the
buttons never get stuck, but the modal-hide/reload/alert only fire if
this bucket is still the one showing; a stale completion for an
abandoned bucket just logs to the console instead.
Verified with a jsdom simulation: opening bucket B while bucket A's
Save is still pending leaves B's Save button disabled and a click on
it a no-op; once A's PUT resolves, B's button re-enables but no
modal.hide()/reload() fires (previously both fired unconditionally).
2. bucketPolicyDeleteBtn had no bucketPolicyLoaded check, unlike Save -
a failed GET blocked Save but left Delete free to remove a policy the
client never actually saw (the same gap already fixed for the S3
Tables policy modals in an earlier round). Added the same guard,
ahead of the confirm() dialog. Verified with jsdom: Delete after a
failed load now sends no DELETE request.
* admin: fix spelling mistake
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<bucket>"
|
||||
// (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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 <textarea>. Default: which + 'PolicyDocument'.
|
||||
// editorBodyId - id of the structured-editor container. Default: which + 'PolicyEditorBody'.
|
||||
// addStatementBtnId - id of the "Add statement" button. Default: which + 'PolicyAddStatementBtn'.
|
||||
// editorTabBtnId - id of the Editor tab button. Default: which + 'PolicyEditorTabBtn'.
|
||||
// jsonTabBtnId - id of the JSON tab button. Default: which + 'PolicyJsonTabBtn'.
|
||||
// actionDatalistId - id of the shared action-suggestions <datalist>. Default: 'policyActionSuggestions'.
|
||||
// resourceDatalistId - id of the shared resource-suggestions <datalist>. Default: 'policyResourceSuggestions'.
|
||||
// principalDatalistId - id of the shared principal-suggestions <datalist>. Default: 'policyPrincipalSuggestions'.
|
||||
// requirePrincipal - if true, a new statement seeds Principal with '*'
|
||||
// instead of leaving it empty. Bucket policies
|
||||
// require a Principal per statement; IAM policies
|
||||
// don't. The server remains the source of truth for
|
||||
// this rule either way - see
|
||||
// policy_engine.ValidateBucketPolicy.
|
||||
// bucket - if set, the Resource autocomplete only offers
|
||||
// this bucket's ARN and ARN/*, instead of fetching
|
||||
// every bucket, and a new statement's Resource is
|
||||
// seeded with arn:aws:s3:::<bucket>/*.
|
||||
function registerPolicyEditor(which, config) {
|
||||
POLICY_EDITOR_CONFIG[which] = Object.assign({
|
||||
textareaId: which + 'PolicyDocument',
|
||||
editorBodyId: which + 'PolicyEditorBody',
|
||||
addStatementBtnId: which + 'PolicyAddStatementBtn',
|
||||
editorTabBtnId: which + 'PolicyEditorTabBtn',
|
||||
jsonTabBtnId: which + 'PolicyJsonTabBtn',
|
||||
actionDatalistId: 'policyActionSuggestions',
|
||||
resourceDatalistId: 'policyResourceSuggestions',
|
||||
principalDatalistId: 'policyPrincipalSuggestions',
|
||||
requirePrincipal: false,
|
||||
bucket: null
|
||||
}, config || {});
|
||||
}
|
||||
|
||||
function policyEditorConfig(which) {
|
||||
return POLICY_EDITOR_CONFIG[which] || (registerPolicyEditor(which, {}), POLICY_EDITOR_CONFIG[which]);
|
||||
}
|
||||
|
||||
// Structured-editor state, one entry per modal ("create" / "edit"). Each
|
||||
// entry is { version, statements: [{ sid, effect, actions, resources, extras }] }.
|
||||
// "extras" holds the JSON text of any statement fields the structured
|
||||
// editor doesn't expose (Principal, NotPrincipal, NotResource, Condition,
|
||||
// or any future/unknown key), so they round-trip untouched.
|
||||
let policyEditors = {
|
||||
create: { version: '2012-10-17', statements: [], otherFields: {} },
|
||||
edit: { version: '2012-10-17', statements: [], otherFields: {} }
|
||||
};
|
||||
|
||||
// Lazily initializes and returns policyEditors[which]. 'create'/'edit'
|
||||
// are pre-populated above, but a page with its own `which` (e.g. the
|
||||
// bucket policy modal) doesn't populate one until its first successful
|
||||
// load - and that load is asynchronous, so the Editor/JSON tabs and
|
||||
// Add-statement button can be reachable before it resolves (nothing in
|
||||
// this file enforces that a page hide them meanwhile). Route reads
|
||||
// through this instead of policyEditors[which] directly wherever that
|
||||
// race is possible, so a click in that window gets a valid empty state
|
||||
// instead of a TypeError on undefined.
|
||||
function policyEditorState(which) {
|
||||
if (!policyEditors[which]) {
|
||||
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {} };
|
||||
}
|
||||
return policyEditors[which];
|
||||
}
|
||||
|
||||
const POLICY_STATEMENT_KNOWN_KEYS = ['Sid', 'Effect', 'Action', 'Resource', 'NotResource'];
|
||||
|
||||
// Shown for a policy that parsed as JSON but that the structured editor
|
||||
// can't represent, so its editor state carries { unparsed: true } and the
|
||||
// document only ever lives in the JSON tab.
|
||||
const POLICY_JSON_TAB_ONLY_MESSAGE = 'This policy can only be edited on the JSON tab.';
|
||||
|
||||
// Maps a policy-list-item's data-field attribute to the editor-state
|
||||
// array it belongs to.
|
||||
const POLICY_LIST_FIELD_TO_STATE_KEY = { action: 'actions', resource: 'resources', principal: 'principalValues' };
|
||||
|
||||
function policyTextareaId(which) {
|
||||
return policyEditorConfig(which).textareaId;
|
||||
}
|
||||
|
||||
function policyEditorBodyId(which) {
|
||||
return policyEditorConfig(which).editorBodyId;
|
||||
}
|
||||
|
||||
function normalizeToStringArray(value) {
|
||||
if (value === undefined || value === null) return [];
|
||||
// Coerce: these feed escapeHtml, which calls text.replace, and a
|
||||
// policy is free to carry a number or a boolean here.
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
const POLICY_DOCUMENT_KNOWN_KEYS = ['Version', 'Statement'];
|
||||
|
||||
// Converts a policy document (as parsed from JSON) into editor state.
|
||||
//
|
||||
// Top-level keys the editor doesn't model (e.g. Id) are kept verbatim in
|
||||
// state.otherFields and merged back on serialization, so a round-trip
|
||||
// through the Editor tab doesn't rewrite text the user typed in the JSON
|
||||
// tab. The admin API's PolicyDocument only carries Version and Statement,
|
||||
// so such fields are still dropped by the server on save; see
|
||||
// confirmPolicyFieldDiscard, which warns the user before that happens.
|
||||
function policyDocToEditorState(doc) {
|
||||
if (doc === null || typeof doc !== 'object') {
|
||||
// null or a bare scalar (string/number/boolean) can't represent a
|
||||
// policy document; treating it as "zero statements" would hide
|
||||
// from the user that their input wasn't actually a document.
|
||||
throw new Error('Policy document must be a JSON object (got ' + JSON.stringify(doc) + ')');
|
||||
}
|
||||
const state = { version: doc.Version || '2012-10-17', statements: [], otherFields: {} };
|
||||
if (!Array.isArray(doc)) {
|
||||
Object.keys(doc).forEach(function(key) {
|
||||
if (POLICY_DOCUMENT_KNOWN_KEYS.indexOf(key) === -1) {
|
||||
state.otherFields[key] = doc[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
const rawStatements = doc && doc.Statement
|
||||
? (Array.isArray(doc.Statement) ? doc.Statement : [doc.Statement])
|
||||
: [];
|
||||
rawStatements.forEach(function(stmt, idx) {
|
||||
stmt = stmt || {};
|
||||
if (stmt.Effect !== 'Allow' && stmt.Effect !== 'Deny') {
|
||||
// Defaulting a missing/malformed Effect to "Allow" would
|
||||
// silently turn e.g. a typo'd "deny" into a permissive
|
||||
// statement. Reject instead of guessing.
|
||||
throw new Error('Statement ' + (idx + 1) + ': Effect must be exactly "Allow" or "Deny" (got ' +
|
||||
JSON.stringify(stmt.Effect === undefined ? null : stmt.Effect) + ')');
|
||||
}
|
||||
const hasResource = Object.prototype.hasOwnProperty.call(stmt, 'Resource');
|
||||
const hasNotResource = Object.prototype.hasOwnProperty.call(stmt, 'NotResource');
|
||||
if (hasResource && hasNotResource) {
|
||||
// The two are mutually exclusive; a document with both isn't
|
||||
// representable by the mode dropdown, so ask the user to fix
|
||||
// it in the JSON tab rather than silently picking one.
|
||||
throw new Error('Statement ' + (idx + 1) + ': cannot specify both Resource and NotResource');
|
||||
}
|
||||
const resourceMode = hasNotResource ? 'NotResource' : 'Resource';
|
||||
|
||||
const hasPrincipal = Object.prototype.hasOwnProperty.call(stmt, 'Principal');
|
||||
const hasNotPrincipal = Object.prototype.hasOwnProperty.call(stmt, 'NotPrincipal');
|
||||
if (hasPrincipal && hasNotPrincipal) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': cannot specify both Principal and NotPrincipal');
|
||||
}
|
||||
let principalMode = 'Principal';
|
||||
let principalValues = [];
|
||||
let principalManaged = false;
|
||||
let hasComplexPrincipal = false;
|
||||
if (hasPrincipal || hasNotPrincipal) {
|
||||
const principalKey = hasNotPrincipal ? 'NotPrincipal' : 'Principal';
|
||||
const parsedValues = parseSimpleAwsPrincipal(stmt[principalKey]);
|
||||
if (parsedValues) {
|
||||
principalMode = principalKey;
|
||||
principalValues = parsedValues;
|
||||
principalManaged = true;
|
||||
} else {
|
||||
// Bare string/array, a type other than AWS, or multiple
|
||||
// types at once - this v1 editor only models a single
|
||||
// {"AWS": ...} form. Leave it in extras rather than
|
||||
// guessing or discarding it.
|
||||
hasComplexPrincipal = true;
|
||||
}
|
||||
}
|
||||
|
||||
const extras = {};
|
||||
Object.keys(stmt).forEach(function(key) {
|
||||
if (POLICY_STATEMENT_KNOWN_KEYS.indexOf(key) !== -1) return;
|
||||
if (principalManaged && (key === 'Principal' || key === 'NotPrincipal')) return;
|
||||
extras[key] = stmt[key];
|
||||
});
|
||||
state.statements.push({
|
||||
sid: String(stmt.Sid || ''),
|
||||
effect: stmt.Effect,
|
||||
actions: normalizeToStringArray(stmt.Action),
|
||||
resourceMode: resourceMode,
|
||||
resources: normalizeToStringArray(hasNotResource ? stmt.NotResource : stmt.Resource),
|
||||
principalMode: principalMode,
|
||||
principalValues: principalValues,
|
||||
hasComplexPrincipal: hasComplexPrincipal,
|
||||
extras: Object.keys(extras).length ? JSON.stringify(extras, null, 2) : ''
|
||||
});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// Returns the flattened list of values if `value` is a policy Principal /
|
||||
// NotPrincipal expressed as one of the two forms this editor's simple
|
||||
// text field models: the bare wildcard "*" (standard AWS shorthand for
|
||||
// "everyone"), or a single-key {"AWS": "..."} / {"AWS": ["...", ...]}
|
||||
// object. Returns null for anything else (any other bare string/array, a
|
||||
// different type key, or multiple type keys at once), which the caller
|
||||
// then leaves untouched in "extras".
|
||||
function parseSimpleAwsPrincipal(value) {
|
||||
if (value === '*') return ['*'];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 1 || keys[0] !== 'AWS') return null;
|
||||
return normalizeToStringArray(value.AWS);
|
||||
}
|
||||
|
||||
// Converts editor state back into a policy document. Structured fields
|
||||
// (Sid/Effect/Action/Resource-or-NotResource) always take precedence over
|
||||
// whatever is in "extras" in case of a conflicting key.
|
||||
//
|
||||
// Throws if a statement's "advanced fields" box holds malformed or
|
||||
// non-object JSON, instead of silently dropping it: those fields can
|
||||
// carry Principal/NotPrincipal/Condition, so silently continuing with an
|
||||
// empty object would change the policy's authorization behavior without
|
||||
// the user noticing.
|
||||
function policyEditorStateToDoc(state) {
|
||||
// Unmanaged top-level keys first, so Version/Statement below always win.
|
||||
const doc = Object.assign({}, state.otherFields || {});
|
||||
doc.Version = state.version || '2012-10-17';
|
||||
doc.Statement = [];
|
||||
(state.statements || []).forEach(function(s, idx) {
|
||||
let stmt = {};
|
||||
if (s.extras && s.extras.trim()) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(s.extras);
|
||||
} catch (e) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': advanced fields contain invalid JSON (' + e.message + ')');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': advanced fields must be a JSON object');
|
||||
}
|
||||
stmt = parsed;
|
||||
}
|
||||
if (s.sid) {
|
||||
stmt.Sid = s.sid;
|
||||
} else {
|
||||
delete stmt.Sid;
|
||||
}
|
||||
stmt.Effect = s.effect === 'Deny' ? 'Deny' : 'Allow';
|
||||
const actions = (s.actions || []).map(function(a) { return (a || '').trim(); }).filter(Boolean);
|
||||
if (actions.length) {
|
||||
stmt.Action = actions.length === 1 ? actions[0] : actions;
|
||||
} else {
|
||||
delete stmt.Action;
|
||||
}
|
||||
const resources = (s.resources || []).map(function(r) { return (r || '').trim(); }).filter(Boolean);
|
||||
if (resources.length) {
|
||||
// Resource and NotResource are mutually exclusive; only the
|
||||
// key matching the selected mode is ever written.
|
||||
if (s.resourceMode === 'NotResource') {
|
||||
stmt.NotResource = resources.length === 1 ? resources[0] : resources;
|
||||
delete stmt.Resource;
|
||||
} else {
|
||||
stmt.Resource = resources.length === 1 ? resources[0] : resources;
|
||||
delete stmt.NotResource;
|
||||
}
|
||||
} else {
|
||||
delete stmt.Resource;
|
||||
delete stmt.NotResource;
|
||||
}
|
||||
const principalValues = (s.principalValues || []).map(function(p) { return (p || '').trim(); }).filter(Boolean);
|
||||
if (principalValues.length) {
|
||||
// Same exclusivity rule as Resource/NotResource. Structured
|
||||
// values always win over whatever "extras" held for these
|
||||
// keys. A lone "*" is written as the bare wildcard (standard
|
||||
// AWS shorthand for "everyone"); anything else is wrapped in
|
||||
// the standard {"AWS": ...} form (v1 only models AWS
|
||||
// principals).
|
||||
delete stmt.Principal;
|
||||
delete stmt.NotPrincipal;
|
||||
const wrappedPrincipal = (principalValues.length === 1 && principalValues[0] === '*')
|
||||
? '*'
|
||||
: { AWS: principalValues.length === 1 ? principalValues[0] : principalValues };
|
||||
if (s.principalMode === 'NotPrincipal') {
|
||||
stmt.NotPrincipal = wrappedPrincipal;
|
||||
} else {
|
||||
stmt.Principal = wrappedPrincipal;
|
||||
}
|
||||
}
|
||||
// If principalValues is empty, leave stmt.Principal/NotPrincipal
|
||||
// untouched: it may hold a complex form preserved verbatim from
|
||||
// "extras" (see parseSimpleAwsPrincipal) that the user never
|
||||
// touched via this field, and clearing it here would silently
|
||||
// discard it.
|
||||
doc.Statement.push(stmt);
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Renders the structured editor for the given modal ("create"/"edit")
|
||||
// from policyEditors[which] into its container.
|
||||
function renderPolicyEditor(which) {
|
||||
const container = document.getElementById(policyEditorBodyId(which));
|
||||
if (!container) return;
|
||||
const state = policyEditorState(which);
|
||||
|
||||
if (state.unparsed) {
|
||||
container.innerHTML = '<p class="text-muted">This policy uses a form the structured editor cannot show. Edit it on the JSON tab.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.statements.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">No statements yet. Click "Add statement" to create one.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
state.statements.forEach(function(stmt, idx) {
|
||||
const actionRows = stmt.actions.map(function(action, actionIdx) {
|
||||
return policyListRowHtml(which, idx, 'action', actionIdx, action);
|
||||
}).join('');
|
||||
const resourceRows = stmt.resources.map(function(resource, resourceIdx) {
|
||||
return policyListRowHtml(which, idx, 'resource', resourceIdx, resource);
|
||||
}).join('');
|
||||
const principalRows = stmt.principalValues.map(function(principal, principalIdx) {
|
||||
return policyListRowHtml(which, idx, 'principal', principalIdx, principal);
|
||||
}).join('');
|
||||
|
||||
html +=
|
||||
'<div class="card mb-3" data-statement-index="' + idx + '">' +
|
||||
'<div class="card-body">' +
|
||||
'<div class="d-flex justify-content-between align-items-start mb-2">' +
|
||||
'<h6 class="card-title mb-0">Statement ' + (idx + 1) + '</h6>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger policy-remove-statement-btn" data-which="' + which + '" data-index="' + idx + '"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="row mb-2">' +
|
||||
'<div class="col-md-6">' +
|
||||
'<label class="form-label">Sid (optional)</label>' +
|
||||
'<input type="text" class="form-control form-control-sm policy-stmt-sid" data-which="' + which + '" data-index="' + idx + '" value="' + escapeHtml(stmt.sid) + '">' +
|
||||
'</div>' +
|
||||
'<div class="col-md-6">' +
|
||||
'<label class="form-label d-block">Effect</label>' +
|
||||
'<div class="btn-group" role="group">' +
|
||||
'<input type="radio" class="btn-check policy-stmt-effect" name="policyEffect-' + which + '-' + idx + '" id="policyEffectAllow-' + which + '-' + idx + '" data-which="' + which + '" data-index="' + idx + '" value="Allow"' + (stmt.effect === 'Allow' ? ' checked' : '') + '>' +
|
||||
'<label class="btn btn-outline-success btn-sm" for="policyEffectAllow-' + which + '-' + idx + '">Allow</label>' +
|
||||
'<input type="radio" class="btn-check policy-stmt-effect" name="policyEffect-' + which + '-' + idx + '" id="policyEffectDeny-' + which + '-' + idx + '" data-which="' + which + '" data-index="' + idx + '" value="Deny"' + (stmt.effect === 'Deny' ? ' checked' : '') + '>' +
|
||||
'<label class="btn btn-outline-danger btn-sm" for="policyEffectDeny-' + which + '-' + idx + '">Deny</label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<fieldset class="policy-stmt-fieldset">' +
|
||||
'<legend class="policy-stmt-legend border rounded">Actions</legend>' +
|
||||
'<div class="policy-action-rows" data-which="' + which + '" data-index="' + idx + '">' + actionRows + '</div>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="action"><i class="fas fa-plus me-1"></i>Add action</button>' +
|
||||
'</fieldset>' +
|
||||
'<fieldset class="policy-stmt-fieldset">' +
|
||||
'<legend class="policy-stmt-legend">' +
|
||||
'<select class="form-select form-select-sm d-inline-block w-auto policy-stmt-resource-mode" data-which="' + which + '" data-index="' + idx + '">' +
|
||||
'<option value="Resource"' + (stmt.resourceMode !== 'NotResource' ? ' selected' : '') + '>Resource</option>' +
|
||||
'<option value="NotResource"' + (stmt.resourceMode === 'NotResource' ? ' selected' : '') + '>NotResource</option>' +
|
||||
'</select>' +
|
||||
'</legend>' +
|
||||
(stmt.resourceMode === 'NotResource' ? '<div class="form-text mt-0 mb-1">The statement applies to every resource except the ones listed.</div>' : '') +
|
||||
'<div class="policy-resource-rows" data-which="' + which + '" data-index="' + idx + '">' + resourceRows + '</div>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="resource"><i class="fas fa-plus me-1"></i>Add resource</button>' +
|
||||
'</fieldset>' +
|
||||
'<fieldset class="policy-stmt-fieldset">' +
|
||||
'<legend class="policy-stmt-legend">' +
|
||||
'<select class="form-select form-select-sm d-inline-block w-auto policy-stmt-principal-mode" data-which="' + which + '" data-index="' + idx + '">' +
|
||||
'<option value="Principal"' + (stmt.principalMode !== 'NotPrincipal' ? ' selected' : '') + '>Principal</option>' +
|
||||
'<option value="NotPrincipal"' + (stmt.principalMode === 'NotPrincipal' ? ' selected' : '') + '>NotPrincipal</option>' +
|
||||
'</select>' +
|
||||
'</legend>' +
|
||||
'<div class="form-text mt-0 mb-1">Principal / NotPrincipal (AWS account/user ARN, or "*" for everyone). Only the AWS type and "*" are supported here; other forms stay editable via Advanced fields.</div>' +
|
||||
(stmt.hasComplexPrincipal ? '<div class="form-text text-warning mt-0 mb-1"><i class="fas fa-triangle-exclamation me-1"></i>This statement\'s Principal/NotPrincipal uses a form not supported by this field — see Advanced fields below.</div>' : '') +
|
||||
'<div class="policy-principal-rows" data-which="' + which + '" data-index="' + idx + '">' + principalRows + '</div>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="principal"><i class="fas fa-plus me-1"></i>Add principal</button>' +
|
||||
'</fieldset>' +
|
||||
'<details class="mt-3"' + (stmt.extras ? ' open' : '') + '>' +
|
||||
'<summary class="text-muted">Advanced fields (Principal, NotPrincipal, Condition, raw JSON)</summary>' +
|
||||
'<textarea class="form-control form-control-sm mt-2 policy-stmt-extras" data-which="' + which + '" data-index="' + idx + '" rows="4" placeholder="{}">' + escapeHtml(stmt.extras) + '</textarea>' +
|
||||
'</details>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
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 '<div class="input-group input-group-sm mb-1">' +
|
||||
'<input type="text" class="form-control policy-list-item" ' + listAttr + ' data-which="' + which + '" data-index="' + stmtIdx + '" data-field="' + field + '" data-item-index="' + itemIdx + '" value="' + escapeHtml(value) + '">' +
|
||||
'<button type="button" class="btn btn-outline-danger policy-remove-list-item-btn" data-which="' + which + '" data-index="' + stmtIdx + '" data-field="' + field + '" data-item-index="' + itemIdx + '"><i class="fas fa-times"></i></button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 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 '<option value="' + escapeHtml(v) + '"></option>';
|
||||
}).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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -198,22 +198,7 @@ templ Policies(data dash.PoliciesData) {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Datalist of suggested action names, shared by the create/edit editors -->
|
||||
<datalist id="policyActionSuggestions">
|
||||
for _, action := range PolicyActionSuggestions {
|
||||
<option value={action}></option>
|
||||
}
|
||||
</datalist>
|
||||
|
||||
<!-- Populated dynamically as the user types in a Resource field: bucket
|
||||
names first, then subfolders one path segment at a time. See
|
||||
updatePolicyResourceSuggestions() below. -->
|
||||
<datalist id="policyResourceSuggestions"></datalist>
|
||||
|
||||
<!-- Populated dynamically as the user types/focuses a Principal field:
|
||||
existing users and IAM roles, fetched once from /api/principals. See
|
||||
updatePolicyPrincipalSuggestions() below. -->
|
||||
<datalist id="policyPrincipalSuggestions"></datalist>
|
||||
@PolicyDatalists()
|
||||
|
||||
<!-- Create Policy Modal -->
|
||||
<div class="modal fade" id="createPolicyModal" tabindex="-1" aria-labelledby="createPolicyModalLabel" aria-hidden="true">
|
||||
@@ -368,729 +353,18 @@ templ Policies(data dash.PoliciesData) {
|
||||
// Current policy being viewed/edited
|
||||
let currentPolicy = null;
|
||||
|
||||
// Structured-editor state, one entry per modal ("create" / "edit"). Each
|
||||
// entry is { version, statements: [{ sid, effect, actions, resources, extras }] }.
|
||||
// "extras" holds the JSON text of any statement fields the structured
|
||||
// editor doesn't expose (Principal, NotPrincipal, NotResource, Condition,
|
||||
// or any future/unknown key), so they round-trip untouched.
|
||||
let policyEditors = {
|
||||
create: { version: '2012-10-17', statements: [], otherFields: {} },
|
||||
edit: { version: '2012-10-17', statements: [], otherFields: {} }
|
||||
};
|
||||
|
||||
const POLICY_STATEMENT_KNOWN_KEYS = ['Sid', 'Effect', 'Action', 'Resource', 'NotResource'];
|
||||
|
||||
// Shown for a policy that parsed as JSON but that the structured editor
|
||||
// can't represent, so its editor state carries { unparsed: true } and the
|
||||
// document only ever lives in the JSON tab.
|
||||
const POLICY_JSON_TAB_ONLY_MESSAGE = 'This policy can only be edited on the JSON tab.';
|
||||
|
||||
// Maps a policy-list-item's data-field attribute to the editor-state
|
||||
// array it belongs to.
|
||||
const POLICY_LIST_FIELD_TO_STATE_KEY = { action: 'actions', resource: 'resources', principal: 'principalValues' };
|
||||
|
||||
function policyTextareaId(which) {
|
||||
return which === 'create' ? 'policyDocument' : 'editPolicyDocument';
|
||||
}
|
||||
|
||||
function policyEditorBodyId(which) {
|
||||
return which + 'PolicyEditorBody';
|
||||
}
|
||||
|
||||
function normalizeToStringArray(value) {
|
||||
if (value === undefined || value === null) return [];
|
||||
// Coerce: these feed escapeHtml, which calls text.replace, and a
|
||||
// policy is free to carry a number or a boolean here.
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
const POLICY_DOCUMENT_KNOWN_KEYS = ['Version', 'Statement'];
|
||||
|
||||
// Converts a policy document (as parsed from JSON) into editor state.
|
||||
//
|
||||
// Top-level keys the editor doesn't model (e.g. Id) are kept verbatim in
|
||||
// state.otherFields and merged back on serialization, so a round-trip
|
||||
// through the Editor tab doesn't rewrite text the user typed in the JSON
|
||||
// tab. The admin API's PolicyDocument only carries Version and Statement,
|
||||
// so such fields are still dropped by the server on save; see
|
||||
// confirmPolicyFieldDiscard, which warns the user before that happens.
|
||||
function policyDocToEditorState(doc) {
|
||||
if (doc === null || typeof doc !== 'object') {
|
||||
// null or a bare scalar (string/number/boolean) can't represent a
|
||||
// policy document; treating it as "zero statements" would hide
|
||||
// from the user that their input wasn't actually a document.
|
||||
throw new Error('Policy document must be a JSON object (got ' + JSON.stringify(doc) + ')');
|
||||
}
|
||||
const state = { version: doc.Version || '2012-10-17', statements: [], otherFields: {} };
|
||||
if (!Array.isArray(doc)) {
|
||||
Object.keys(doc).forEach(function(key) {
|
||||
if (POLICY_DOCUMENT_KNOWN_KEYS.indexOf(key) === -1) {
|
||||
state.otherFields[key] = doc[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
const rawStatements = doc && doc.Statement
|
||||
? (Array.isArray(doc.Statement) ? doc.Statement : [doc.Statement])
|
||||
: [];
|
||||
rawStatements.forEach(function(stmt, idx) {
|
||||
stmt = stmt || {};
|
||||
if (stmt.Effect !== 'Allow' && stmt.Effect !== 'Deny') {
|
||||
// Defaulting a missing/malformed Effect to "Allow" would
|
||||
// silently turn e.g. a typo'd "deny" into a permissive
|
||||
// statement. Reject instead of guessing.
|
||||
throw new Error('Statement ' + (idx + 1) + ': Effect must be exactly "Allow" or "Deny" (got ' +
|
||||
JSON.stringify(stmt.Effect === undefined ? null : stmt.Effect) + ')');
|
||||
}
|
||||
const hasResource = Object.prototype.hasOwnProperty.call(stmt, 'Resource');
|
||||
const hasNotResource = Object.prototype.hasOwnProperty.call(stmt, 'NotResource');
|
||||
if (hasResource && hasNotResource) {
|
||||
// The two are mutually exclusive; a document with both isn't
|
||||
// representable by the mode dropdown, so ask the user to fix
|
||||
// it in the JSON tab rather than silently picking one.
|
||||
throw new Error('Statement ' + (idx + 1) + ': cannot specify both Resource and NotResource');
|
||||
}
|
||||
const resourceMode = hasNotResource ? 'NotResource' : 'Resource';
|
||||
|
||||
const hasPrincipal = Object.prototype.hasOwnProperty.call(stmt, 'Principal');
|
||||
const hasNotPrincipal = Object.prototype.hasOwnProperty.call(stmt, 'NotPrincipal');
|
||||
if (hasPrincipal && hasNotPrincipal) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': cannot specify both Principal and NotPrincipal');
|
||||
}
|
||||
let principalMode = 'Principal';
|
||||
let principalValues = [];
|
||||
let principalManaged = false;
|
||||
let hasComplexPrincipal = false;
|
||||
if (hasPrincipal || hasNotPrincipal) {
|
||||
const principalKey = hasNotPrincipal ? 'NotPrincipal' : 'Principal';
|
||||
const parsedValues = parseSimpleAwsPrincipal(stmt[principalKey]);
|
||||
if (parsedValues) {
|
||||
principalMode = principalKey;
|
||||
principalValues = parsedValues;
|
||||
principalManaged = true;
|
||||
} else {
|
||||
// Bare string/array, a type other than AWS, or multiple
|
||||
// types at once - this v1 editor only models a single
|
||||
// {"AWS": ...} form. Leave it in extras rather than
|
||||
// guessing or discarding it.
|
||||
hasComplexPrincipal = true;
|
||||
}
|
||||
}
|
||||
|
||||
const extras = {};
|
||||
Object.keys(stmt).forEach(function(key) {
|
||||
if (POLICY_STATEMENT_KNOWN_KEYS.indexOf(key) !== -1) return;
|
||||
if (principalManaged && (key === 'Principal' || key === 'NotPrincipal')) return;
|
||||
extras[key] = stmt[key];
|
||||
});
|
||||
state.statements.push({
|
||||
sid: String(stmt.Sid || ''),
|
||||
effect: stmt.Effect,
|
||||
actions: normalizeToStringArray(stmt.Action),
|
||||
resourceMode: resourceMode,
|
||||
resources: normalizeToStringArray(hasNotResource ? stmt.NotResource : stmt.Resource),
|
||||
principalMode: principalMode,
|
||||
principalValues: principalValues,
|
||||
hasComplexPrincipal: hasComplexPrincipal,
|
||||
extras: Object.keys(extras).length ? JSON.stringify(extras, null, 2) : ''
|
||||
});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// Returns the flattened list of values if `value` is a policy Principal /
|
||||
// NotPrincipal expressed as one of the two forms this editor's simple
|
||||
// text field models: the bare wildcard "*" (standard AWS shorthand for
|
||||
// "everyone"), or a single-key {"AWS": "..."} / {"AWS": ["...", ...]}
|
||||
// object. Returns null for anything else (any other bare string/array, a
|
||||
// different type key, or multiple type keys at once), which the caller
|
||||
// then leaves untouched in "extras".
|
||||
function parseSimpleAwsPrincipal(value) {
|
||||
if (value === '*') return ['*'];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 1 || keys[0] !== 'AWS') return null;
|
||||
return normalizeToStringArray(value.AWS);
|
||||
}
|
||||
|
||||
// Converts editor state back into a policy document. Structured fields
|
||||
// (Sid/Effect/Action/Resource-or-NotResource) always take precedence over
|
||||
// whatever is in "extras" in case of a conflicting key.
|
||||
//
|
||||
// Throws if a statement's "advanced fields" box holds malformed or
|
||||
// non-object JSON, instead of silently dropping it: those fields can
|
||||
// carry Principal/NotPrincipal/Condition, so silently continuing with an
|
||||
// empty object would change the policy's authorization behavior without
|
||||
// the user noticing.
|
||||
function policyEditorStateToDoc(state) {
|
||||
// Unmanaged top-level keys first, so Version/Statement below always win.
|
||||
const doc = Object.assign({}, state.otherFields || {});
|
||||
doc.Version = state.version || '2012-10-17';
|
||||
doc.Statement = [];
|
||||
(state.statements || []).forEach(function(s, idx) {
|
||||
let stmt = {};
|
||||
if (s.extras && s.extras.trim()) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(s.extras);
|
||||
} catch (e) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': advanced fields contain invalid JSON (' + e.message + ')');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': advanced fields must be a JSON object');
|
||||
}
|
||||
stmt = parsed;
|
||||
}
|
||||
if (s.sid) {
|
||||
stmt.Sid = s.sid;
|
||||
} else {
|
||||
delete stmt.Sid;
|
||||
}
|
||||
stmt.Effect = s.effect === 'Deny' ? 'Deny' : 'Allow';
|
||||
const actions = (s.actions || []).map(function(a) { return (a || '').trim(); }).filter(Boolean);
|
||||
if (actions.length) {
|
||||
stmt.Action = actions.length === 1 ? actions[0] : actions;
|
||||
} else {
|
||||
delete stmt.Action;
|
||||
}
|
||||
const resources = (s.resources || []).map(function(r) { return (r || '').trim(); }).filter(Boolean);
|
||||
if (resources.length) {
|
||||
// Resource and NotResource are mutually exclusive; only the
|
||||
// key matching the selected mode is ever written.
|
||||
if (s.resourceMode === 'NotResource') {
|
||||
stmt.NotResource = resources.length === 1 ? resources[0] : resources;
|
||||
delete stmt.Resource;
|
||||
} else {
|
||||
stmt.Resource = resources.length === 1 ? resources[0] : resources;
|
||||
delete stmt.NotResource;
|
||||
}
|
||||
} else {
|
||||
delete stmt.Resource;
|
||||
delete stmt.NotResource;
|
||||
}
|
||||
const principalValues = (s.principalValues || []).map(function(p) { return (p || '').trim(); }).filter(Boolean);
|
||||
if (principalValues.length) {
|
||||
// Same exclusivity rule as Resource/NotResource. Structured
|
||||
// values always win over whatever "extras" held for these
|
||||
// keys. A lone "*" is written as the bare wildcard (standard
|
||||
// AWS shorthand for "everyone"); anything else is wrapped in
|
||||
// the standard {"AWS": ...} form (v1 only models AWS
|
||||
// principals).
|
||||
delete stmt.Principal;
|
||||
delete stmt.NotPrincipal;
|
||||
const wrappedPrincipal = (principalValues.length === 1 && principalValues[0] === '*')
|
||||
? '*'
|
||||
: { AWS: principalValues.length === 1 ? principalValues[0] : principalValues };
|
||||
if (s.principalMode === 'NotPrincipal') {
|
||||
stmt.NotPrincipal = wrappedPrincipal;
|
||||
} else {
|
||||
stmt.Principal = wrappedPrincipal;
|
||||
}
|
||||
}
|
||||
// If principalValues is empty, leave stmt.Principal/NotPrincipal
|
||||
// untouched: it may hold a complex form preserved verbatim from
|
||||
// "extras" (see parseSimpleAwsPrincipal) that the user never
|
||||
// touched via this field, and clearing it here would silently
|
||||
// discard it.
|
||||
doc.Statement.push(stmt);
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Renders the structured editor for the given modal ("create"/"edit")
|
||||
// from policyEditors[which] into its container.
|
||||
function renderPolicyEditor(which) {
|
||||
const container = document.getElementById(policyEditorBodyId(which));
|
||||
if (!container) return;
|
||||
const state = policyEditors[which];
|
||||
|
||||
if (state.unparsed) {
|
||||
container.innerHTML = '<p class="text-muted">This policy uses a form the structured editor cannot show. Edit it on the JSON tab.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.statements.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">No statements yet. Click "Add statement" to create one.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
state.statements.forEach(function(stmt, idx) {
|
||||
const actionRows = stmt.actions.map(function(action, actionIdx) {
|
||||
return policyListRowHtml(which, idx, 'action', actionIdx, action);
|
||||
}).join('');
|
||||
const resourceRows = stmt.resources.map(function(resource, resourceIdx) {
|
||||
return policyListRowHtml(which, idx, 'resource', resourceIdx, resource);
|
||||
}).join('');
|
||||
const principalRows = stmt.principalValues.map(function(principal, principalIdx) {
|
||||
return policyListRowHtml(which, idx, 'principal', principalIdx, principal);
|
||||
}).join('');
|
||||
|
||||
html +=
|
||||
'<div class="card mb-3" data-statement-index="' + idx + '">' +
|
||||
'<div class="card-body">' +
|
||||
'<div class="d-flex justify-content-between align-items-start mb-2">' +
|
||||
'<h6 class="card-title mb-0">Statement ' + (idx + 1) + '</h6>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger policy-remove-statement-btn" data-which="' + which + '" data-index="' + idx + '"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="row mb-2">' +
|
||||
'<div class="col-md-6">' +
|
||||
'<label class="form-label">Sid (optional)</label>' +
|
||||
'<input type="text" class="form-control form-control-sm policy-stmt-sid" data-which="' + which + '" data-index="' + idx + '" value="' + escapeHtml(stmt.sid) + '">' +
|
||||
'</div>' +
|
||||
'<div class="col-md-6">' +
|
||||
'<label class="form-label d-block">Effect</label>' +
|
||||
'<div class="btn-group" role="group">' +
|
||||
'<input type="radio" class="btn-check policy-stmt-effect" name="policyEffect-' + which + '-' + idx + '" id="policyEffectAllow-' + which + '-' + idx + '" data-which="' + which + '" data-index="' + idx + '" value="Allow"' + (stmt.effect === 'Allow' ? ' checked' : '') + '>' +
|
||||
'<label class="btn btn-outline-success btn-sm" for="policyEffectAllow-' + which + '-' + idx + '">Allow</label>' +
|
||||
'<input type="radio" class="btn-check policy-stmt-effect" name="policyEffect-' + which + '-' + idx + '" id="policyEffectDeny-' + which + '-' + idx + '" data-which="' + which + '" data-index="' + idx + '" value="Deny"' + (stmt.effect === 'Deny' ? ' checked' : '') + '>' +
|
||||
'<label class="btn btn-outline-danger btn-sm" for="policyEffectDeny-' + which + '-' + idx + '">Deny</label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<fieldset class="policy-stmt-fieldset">' +
|
||||
'<legend class="policy-stmt-legend border rounded">Actions</legend>' +
|
||||
'<div class="policy-action-rows" data-which="' + which + '" data-index="' + idx + '">' + actionRows + '</div>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="action"><i class="fas fa-plus me-1"></i>Add action</button>' +
|
||||
'</fieldset>' +
|
||||
'<fieldset class="policy-stmt-fieldset">' +
|
||||
'<legend class="policy-stmt-legend">' +
|
||||
'<select class="form-select form-select-sm d-inline-block w-auto policy-stmt-resource-mode" data-which="' + which + '" data-index="' + idx + '">' +
|
||||
'<option value="Resource"' + (stmt.resourceMode !== 'NotResource' ? ' selected' : '') + '>Resource</option>' +
|
||||
'<option value="NotResource"' + (stmt.resourceMode === 'NotResource' ? ' selected' : '') + '>NotResource</option>' +
|
||||
'</select>' +
|
||||
'</legend>' +
|
||||
(stmt.resourceMode === 'NotResource' ? '<div class="form-text mt-0 mb-1">The statement applies to every resource except the ones listed.</div>' : '') +
|
||||
'<div class="policy-resource-rows" data-which="' + which + '" data-index="' + idx + '">' + resourceRows + '</div>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="resource"><i class="fas fa-plus me-1"></i>Add resource</button>' +
|
||||
'</fieldset>' +
|
||||
'<fieldset class="policy-stmt-fieldset">' +
|
||||
'<legend class="policy-stmt-legend">' +
|
||||
'<select class="form-select form-select-sm d-inline-block w-auto policy-stmt-principal-mode" data-which="' + which + '" data-index="' + idx + '">' +
|
||||
'<option value="Principal"' + (stmt.principalMode !== 'NotPrincipal' ? ' selected' : '') + '>Principal</option>' +
|
||||
'<option value="NotPrincipal"' + (stmt.principalMode === 'NotPrincipal' ? ' selected' : '') + '>NotPrincipal</option>' +
|
||||
'</select>' +
|
||||
'</legend>' +
|
||||
'<div class="form-text mt-0 mb-1">Principal / NotPrincipal (AWS account/user ARN, or "*" for everyone). Only the AWS type and "*" are supported here; other forms stay editable via Advanced fields.</div>' +
|
||||
(stmt.hasComplexPrincipal ? '<div class="form-text text-warning mt-0 mb-1"><i class="fas fa-triangle-exclamation me-1"></i>This statement\'s Principal/NotPrincipal uses a form not supported by this field — see Advanced fields below.</div>' : '') +
|
||||
'<div class="policy-principal-rows" data-which="' + which + '" data-index="' + idx + '">' + principalRows + '</div>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="principal"><i class="fas fa-plus me-1"></i>Add principal</button>' +
|
||||
'</fieldset>' +
|
||||
'<details class="mt-3"' + (stmt.extras ? ' open' : '') + '>' +
|
||||
'<summary class="text-muted">Advanced fields (Principal, NotPrincipal, Condition, raw JSON)</summary>' +
|
||||
'<textarea class="form-control form-control-sm mt-2 policy-stmt-extras" data-which="' + which + '" data-index="' + idx + '" rows="4" placeholder="{}">' + escapeHtml(stmt.extras) + '</textarea>' +
|
||||
'</details>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function policyListRowHtml(which, stmtIdx, field, itemIdx, value) {
|
||||
let listAttr = '';
|
||||
if (field === 'action') listAttr = ' list="policyActionSuggestions"';
|
||||
else if (field === 'resource') listAttr = ' list="policyResourceSuggestions"';
|
||||
else if (field === 'principal') listAttr = ' list="policyPrincipalSuggestions"';
|
||||
return '<div class="input-group input-group-sm mb-1">' +
|
||||
'<input type="text" class="form-control policy-list-item" ' + listAttr + ' data-which="' + which + '" data-index="' + stmtIdx + '" data-field="' + field + '" data-item-index="' + itemIdx + '" value="' + escapeHtml(value) + '">' +
|
||||
'<button type="button" class="btn btn-outline-danger policy-remove-list-item-btn" data-which="' + which + '" data-index="' + stmtIdx + '" data-field="' + field + '" data-item-index="' + itemIdx + '"><i class="fas fa-times"></i></button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 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 addPolicyStatement(which) {
|
||||
if (policyEditors[which].unparsed) {
|
||||
showAlert(POLICY_JSON_TAB_ONLY_MESSAGE, 'error');
|
||||
return;
|
||||
}
|
||||
commitPolicyEditorForm(which);
|
||||
policyEditors[which].statements.push({
|
||||
sid: '', effect: 'Allow', actions: [],
|
||||
resourceMode: 'Resource', resources: [],
|
||||
principalMode: 'Principal', principalValues: [], 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(which + 'PolicyJsonTabBtn');
|
||||
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 (policyEditors[which] && policyEditors[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?');
|
||||
}
|
||||
|
||||
function setupPolicyEditor(which) {
|
||||
document.getElementById(which + 'PolicyAddStatementBtn').addEventListener('click', function() {
|
||||
addPolicyStatement(which);
|
||||
});
|
||||
|
||||
const editorTabBtn = document.getElementById(which + 'PolicyEditorTabBtn');
|
||||
const jsonTabBtn = document.getElementById(which + 'PolicyJsonTabBtn');
|
||||
|
||||
jsonTabBtn.addEventListener('show.bs.tab', function(event) {
|
||||
if (policyEditors[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(target);
|
||||
}
|
||||
});
|
||||
body.addEventListener('focusin', function(event) {
|
||||
const target = event.target;
|
||||
if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'resource') {
|
||||
updatePolicyResourceSuggestions(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();
|
||||
}
|
||||
});
|
||||
body.addEventListener('focusin', function(event) {
|
||||
const target = event.target;
|
||||
if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'principal') {
|
||||
updatePolicyPrincipalSuggestions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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 '<option value="' + escapeHtml(v) + '"></option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updatePolicyResourceSuggestions(inputEl) {
|
||||
const datalist = document.getElementById('policyResourceSuggestions');
|
||||
if (!datalist) return;
|
||||
const state = policyResourcePathState(inputEl.value);
|
||||
|
||||
if (state.stage === 'bucket') {
|
||||
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() {
|
||||
const datalist = document.getElementById('policyPrincipalSuggestions');
|
||||
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) {
|
||||
policyEditors[which] = policyDocToEditorState(POLICY_SAMPLE_DOCUMENT);
|
||||
renderPolicyEditor(which);
|
||||
document.getElementById(policyTextareaId(which)).value = JSON.stringify(POLICY_SAMPLE_DOCUMENT, null, 2);
|
||||
}
|
||||
|
||||
// Event listeners for policy actions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Register the two editor instances used on this page. Config
|
||||
// overrides here preserve the pre-extraction ids exactly, so this
|
||||
// page's markup did not need to change. This has to run after
|
||||
// DOMContentLoaded (not at the top of this script) because
|
||||
// policy_editor.js - which defines registerPolicyEditor - is
|
||||
// loaded by layout.templ's script bundle at the end of <body>,
|
||||
// i.e. after this page's own content (including this inline
|
||||
// script) has already been parsed and run.
|
||||
registerPolicyEditor('create', { textareaId: 'policyDocument' });
|
||||
registerPolicyEditor('edit', { textareaId: 'editPolicyDocument' });
|
||||
setupPolicyEditor('create');
|
||||
setupPolicyEditor('edit');
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
||||
package app
|
||||
|
||||
// PolicyDatalists renders the three <datalist> elements the shared visual
|
||||
// policy editor (weed/admin/static/js/policy_editor.js) attaches its
|
||||
// action/resource/principal <input list="..."> 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() {
|
||||
<!-- Datalist of suggested action names, shared by every policy editor on this page -->
|
||||
<datalist id="policyActionSuggestions">
|
||||
for _, action := range PolicyActionSuggestions {
|
||||
<option value={ action }></option>
|
||||
}
|
||||
</datalist>
|
||||
|
||||
<!-- Populated dynamically as the user types in a Resource field: bucket
|
||||
names first, then subfolders one path segment at a time. See
|
||||
updatePolicyResourceSuggestions() in policy_editor.js. -->
|
||||
<datalist id="policyResourceSuggestions"></datalist>
|
||||
|
||||
<!-- Populated dynamically as the user types/focuses a Principal field:
|
||||
existing users and IAM roles, fetched once from /api/principals. See
|
||||
updatePolicyPrincipalSuggestions() in policy_editor.js. -->
|
||||
<datalist id="policyPrincipalSuggestions"></datalist>
|
||||
}
|
||||
@@ -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 <datalist> elements the shared visual
|
||||
// policy editor (weed/admin/static/js/policy_editor.js) attaches its
|
||||
// action/resource/principal <input list="..."> 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, "<!-- Datalist of suggested action names, shared by every policy editor on this page --><datalist id=\"policyActionSuggestions\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, action := range PolicyActionSuggestions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(action)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/policy_datalists.templ`, Line: 15, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</datalist><!-- Populated dynamically as the user types in a Resource field: bucket\n\t names first, then subfolders one path segment at a time. See\n\t updatePolicyResourceSuggestions() in policy_editor.js. --><datalist id=\"policyResourceSuggestions\"></datalist><!-- Populated dynamically as the user types/focuses a Principal field:\n\t existing users and IAM roles, fetched once from /api/principals. See\n\t updatePolicyPrincipalSuggestions() in policy_editor.js. --><datalist id=\"policyPrincipalSuggestions\"></datalist>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -151,6 +151,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
<th>Versioning</th>
|
||||
<th>Object Lock</th>
|
||||
<th>Lifecycle</th>
|
||||
<th>Policy</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -255,6 +256,21 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
<span class="text-muted">Not configured</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
if bucket.PolicyStatementCount > 0 {
|
||||
<button type="button" class="badge bg-primary border-0 policy-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
title="View Bucket Policy">
|
||||
if bucket.PolicyStatementCount == 1 {
|
||||
1 statement
|
||||
} else {
|
||||
{fmt.Sprintf("%d statements", bucket.PolicyStatementCount)}
|
||||
}
|
||||
</button>
|
||||
} else {
|
||||
<span class="text-muted">Not configured</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href={dash.PUrl(ctx, fmt.Sprintf("/files?path=/buckets/%s", bucket.Name))}
|
||||
@@ -274,7 +290,13 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
title="Lifecycle Rules">
|
||||
<i class="fas fa-recycle"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
<button type="button"
|
||||
class="btn btn-outline-info btn-sm policy-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
title="Bucket Policy">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-info btn-sm owner-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
data-current-owner={bucket.Owner}
|
||||
@@ -301,7 +323,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
}
|
||||
if len(data.Buckets) == 0 {
|
||||
<tr>
|
||||
<td colspan="10" class="text-center text-muted py-4">
|
||||
<td colspan="11" class="text-center text-muted py-4">
|
||||
<i class="fas fa-cube fa-3x mb-3 text-muted"></i>
|
||||
<div>
|
||||
<h5>No Object Store buckets found</h5>
|
||||
@@ -661,6 +683,73 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@PolicyDatalists()
|
||||
|
||||
<!-- Bucket Policy Modal -->
|
||||
<div class="modal fade" id="bucketPolicyModal" tabindex="-1" aria-labelledby="bucketPolicyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="bucketPolicyModalLabel">
|
||||
<i class="fas fa-file-shield me-2"></i>Bucket Policy
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="bucketPolicyLoading" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-2">Loading bucket policy...</div>
|
||||
</div>
|
||||
<div id="bucketPolicyError" class="alert alert-danger" style="display: none;"></div>
|
||||
<div id="bucketPolicyEditorWrapper" style="display: none;">
|
||||
<p class="text-muted small">
|
||||
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.
|
||||
</p>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="bucketPolicyEditorTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#bucketPolicyEditorTab" role="tab" aria-controls="bucketPolicyEditorTab" aria-selected="true">Editor</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="bucketPolicyJsonTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#bucketPolicyJsonTab" role="tab" aria-controls="bucketPolicyJsonTab" aria-selected="false">JSON</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content border border-top-0 rounded-bottom p-3 mb-3">
|
||||
<div class="tab-pane fade show active" id="bucketPolicyEditorTab" role="tabpanel" aria-labelledby="bucketPolicyEditorTabBtn">
|
||||
<div id="bucketPolicyEditorBody"></div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bucketPolicyAddStatementBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add statement
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="bucketPolicyJsonTab" role="tabpanel" aria-labelledby="bucketPolicyJsonTabBtn">
|
||||
<textarea class="form-control" id="bucketPolicyDocument" rows="15"
|
||||
style="font-family: monospace;" spellcheck="false"
|
||||
placeholder='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket-name/*"}]}'></textarea>
|
||||
<div class="form-text">Enter the bucket policy document as JSON</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" id="bucketPolicyInsertSampleBtn">
|
||||
<i class="fas fa-file-alt me-1"></i>Use Sample Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-danger me-auto" id="bucketPolicyDeleteBtn">
|
||||
<i class="fas fa-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="bucketPolicySaveBtn">
|
||||
<i class="fas fa-save me-1"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Owner Modal -->
|
||||
<div class="modal fade" id="manageOwnerModal" tabindex="-1" aria-labelledby="manageOwnerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
@@ -716,6 +805,30 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
let detailsModalInstance = null;
|
||||
let lifecycleModalInstance = null;
|
||||
let lifecycleRequestSeq = 0;
|
||||
let policyModalInstance = null;
|
||||
let policyRequestSeq = 0;
|
||||
let policyEditorBucket = null;
|
||||
// True only once the bucket-policy GET for the currently open bucket has
|
||||
// actually completed successfully. The Save button lives outside the
|
||||
// (initially hidden) editor wrapper, so it stays clickable while a load
|
||||
// is in flight or has failed; without this guard, Save could commit
|
||||
// whatever the editor happened to hold - a stale previous bucket's
|
||||
// statements, or a freshly-initialized empty state - onto
|
||||
// policyEditorBucket before its own policy ever loaded.
|
||||
let bucketPolicyLoaded = false;
|
||||
// True while a Save (PUT) or Delete (DELETE) request for the bucket
|
||||
// policy is in flight, so a double-click - or Save and Delete fired in
|
||||
// quick succession - can't send two overlapping mutations for the same
|
||||
// bucket. Cleared on failure so the user can retry; left set on success,
|
||||
// since the modal hides and the page reloads shortly after anyway.
|
||||
let bucketPolicyMutationInFlight = false;
|
||||
|
||||
function setBucketPolicyMutationInFlight(inFlight) {
|
||||
bucketPolicyMutationInFlight = inFlight;
|
||||
document.getElementById('bucketPolicySaveBtn').disabled = inFlight;
|
||||
document.getElementById('bucketPolicyDeleteBtn').disabled = inFlight;
|
||||
}
|
||||
|
||||
let cachedUsers = null;
|
||||
|
||||
// Working copy of the lifecycle rules currently shown in the modal.
|
||||
@@ -797,6 +910,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
ownerModalInstance = new bootstrap.Modal(document.getElementById('manageOwnerModal'));
|
||||
detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
|
||||
lifecycleModalInstance = new bootstrap.Modal(document.getElementById('bucketLifecycleModal'));
|
||||
policyModalInstance = new bootstrap.Modal(document.getElementById('bucketPolicyModal'));
|
||||
|
||||
const quotaCheckbox = document.getElementById('enableQuota');
|
||||
const quotaSettings = document.getElementById('quotaSettings');
|
||||
@@ -1248,6 +1362,198 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
alert('Error deleting lifecycle rules: ' + error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Policy buttons: column badge and action button. The visual editor
|
||||
// (weed/admin/static/js/policy_editor.js) is set up once per page
|
||||
// load; registerPolicyEditor is re-called on every open so the
|
||||
// resource-ARN autocomplete and default Principal stay pinned to
|
||||
// whichever bucket is currently open.
|
||||
let bucketPolicyEditorSetUp = false;
|
||||
document.getElementById('bucketPolicyInsertSampleBtn').addEventListener('click', function() {
|
||||
if (!policyEditorBucket) return;
|
||||
insertSamplePolicy('bucket', {
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: ['arn:aws:s3:::' + policyEditorBucket + '/*']
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.policy-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const bucketName = this.dataset.bucketName;
|
||||
policyEditorBucket = bucketName;
|
||||
bucketPolicyLoaded = false;
|
||||
// Deliberately not resetting bucketPolicyMutationInFlight
|
||||
// here: if a Save/Delete for a previously open bucket is
|
||||
// still in flight, Save/Delete for this bucket must stay
|
||||
// blocked until that request settles (its own completion
|
||||
// handler releases the flag once done, whether or not it's
|
||||
// still the active bucket by then) - otherwise this bucket's
|
||||
// mutation could race the abandoned one.
|
||||
registerPolicyEditor('bucket', { requirePrincipal: true, bucket: bucketName });
|
||||
if (!bucketPolicyEditorSetUp) {
|
||||
setupPolicyEditor('bucket');
|
||||
bucketPolicyEditorSetUp = true;
|
||||
}
|
||||
|
||||
document.getElementById('bucketPolicyModalLabel').innerHTML =
|
||||
'<i class="fas fa-file-shield me-2"></i>Bucket Policy - ' + escapeHtml(bucketName);
|
||||
document.getElementById('bucketPolicyLoading').style.display = '';
|
||||
document.getElementById('bucketPolicyError').style.display = 'none';
|
||||
document.getElementById('bucketPolicyEditorWrapper').style.display = 'none';
|
||||
|
||||
policyModalInstance.show();
|
||||
|
||||
// Drop responses that arrive after another bucket was opened
|
||||
const requestSeq = ++policyRequestSeq;
|
||||
fetch(basePath('/api/s3/buckets/' + bucketName + '/policy'))
|
||||
.then(parseLifecycleResponse)
|
||||
.then(({ ok, data }) => {
|
||||
if (requestSeq !== policyRequestSeq) return;
|
||||
document.getElementById('bucketPolicyLoading').style.display = 'none';
|
||||
if (!ok || data.error) {
|
||||
document.getElementById('bucketPolicyError').textContent =
|
||||
'Error loading bucket policy: ' + (data.error || 'unknown error');
|
||||
document.getElementById('bucketPolicyError').style.display = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('bucketPolicyEditorWrapper').style.display = '';
|
||||
document.getElementById('bucketPolicyDocument').value =
|
||||
data.policy ? JSON.stringify(data.policy, null, 2) : '';
|
||||
bucketPolicyLoaded = true;
|
||||
loadPolicyTextareaIntoEditor('bucket');
|
||||
})
|
||||
.catch(error => {
|
||||
if (requestSeq !== policyRequestSeq) return;
|
||||
console.error('Error fetching bucket policy:', error);
|
||||
document.getElementById('bucketPolicyLoading').style.display = 'none';
|
||||
document.getElementById('bucketPolicyError').textContent =
|
||||
'Error loading bucket policy: ' + error.message;
|
||||
document.getElementById('bucketPolicyError').style.display = '';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Save the policy document currently shown (whichever tab is active).
|
||||
document.getElementById('bucketPolicySaveBtn').addEventListener('click', function() {
|
||||
if (!policyEditorBucket) return;
|
||||
if (bucketPolicyMutationInFlight) return;
|
||||
if (!bucketPolicyLoaded) {
|
||||
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Commit first: if the Editor tab is active, the textarea still
|
||||
// holds whatever was there at load time until this runs, so a
|
||||
// policy entered purely through the structured form (no tab
|
||||
// switch) would otherwise read back as empty here.
|
||||
if (!commitPolicyActiveTab('bucket')) return;
|
||||
|
||||
const raw = document.getElementById('bucketPolicyDocument').value.trim();
|
||||
if (!raw) {
|
||||
alert('Enter a policy document, or use Delete to remove the bucket policy.');
|
||||
return;
|
||||
}
|
||||
|
||||
let policy;
|
||||
try {
|
||||
policy = JSON.parse(document.getElementById('bucketPolicyDocument').value);
|
||||
} catch (e) {
|
||||
alert('Invalid JSON: ' + e.message);
|
||||
return;
|
||||
}
|
||||
const validationError = validatePolicyEditorDoc('bucket', policy);
|
||||
if (validationError) {
|
||||
alert(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Captured now: if the dialog moves on to a different bucket
|
||||
// before this PUT resolves, the completion below must not hide
|
||||
// or reload over whatever that other bucket is now showing.
|
||||
const targetBucket = policyEditorBucket;
|
||||
setBucketPolicyMutationInFlight(true);
|
||||
fetch(basePath('/api/s3/buckets/' + targetBucket + '/policy'), {
|
||||
method: 'PUT',
|
||||
headers: csrfHeaders(),
|
||||
body: JSON.stringify({ policy: policy })
|
||||
})
|
||||
.then(parseLifecycleResponse)
|
||||
.then(({ ok, data }) => {
|
||||
setBucketPolicyMutationInFlight(false);
|
||||
const stillCurrent = targetBucket === policyEditorBucket;
|
||||
if (!ok || data.error) {
|
||||
if (stillCurrent) {
|
||||
alert('Error saving bucket policy: ' + (data.error || 'unknown error'));
|
||||
} else {
|
||||
console.error('Error saving policy for ' + targetBucket + ' (no longer the open bucket): ' + (data.error || 'unknown error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!stillCurrent) return;
|
||||
if (policyModalInstance) {
|
||||
policyModalInstance.hide();
|
||||
}
|
||||
setTimeout(() => location.reload(), 500);
|
||||
})
|
||||
.catch(error => {
|
||||
setBucketPolicyMutationInFlight(false);
|
||||
console.error('Error:', error);
|
||||
if (targetBucket === policyEditorBucket) {
|
||||
alert('Error saving bucket policy: ' + error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clear the bucket policy entirely.
|
||||
document.getElementById('bucketPolicyDeleteBtn').addEventListener('click', function() {
|
||||
if (!policyEditorBucket) return;
|
||||
if (bucketPolicyMutationInFlight) return;
|
||||
if (!bucketPolicyLoaded) {
|
||||
alert('The current policy has not finished loading. Close and reopen this dialog before deleting.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Delete the bucket policy for ' + policyEditorBucket + '? This cannot be undone.')) return;
|
||||
|
||||
// Captured now: if the dialog moves on to a different bucket
|
||||
// before this DELETE resolves, the completion below must not
|
||||
// hide or reload over whatever that other bucket is now showing.
|
||||
const targetBucket = policyEditorBucket;
|
||||
setBucketPolicyMutationInFlight(true);
|
||||
fetch(basePath('/api/s3/buckets/' + targetBucket + '/policy'), {
|
||||
method: 'DELETE',
|
||||
headers: csrfHeaders()
|
||||
})
|
||||
.then(parseLifecycleResponse)
|
||||
.then(({ ok, data }) => {
|
||||
setBucketPolicyMutationInFlight(false);
|
||||
const stillCurrent = targetBucket === policyEditorBucket;
|
||||
if (!ok || data.error) {
|
||||
if (stillCurrent) {
|
||||
alert('Error deleting bucket policy: ' + (data.error || 'unknown error'));
|
||||
} else {
|
||||
console.error('Error deleting policy for ' + targetBucket + ' (no longer the open bucket): ' + (data.error || 'unknown error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!stillCurrent) return;
|
||||
if (policyModalInstance) {
|
||||
policyModalInstance.hide();
|
||||
}
|
||||
setTimeout(() => location.reload(), 500);
|
||||
})
|
||||
.catch(error => {
|
||||
setBucketPolicyMutationInFlight(false);
|
||||
console.error('Error:', error);
|
||||
if (targetBucket === policyEditorBucket) {
|
||||
alert('Error deleting bucket policy: ' + error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function deleteBucket() {
|
||||
@@ -1321,6 +1627,12 @@ function displayBucketDetails(data) {
|
||||
objectLockHtml = '<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' + details;
|
||||
}
|
||||
|
||||
let policyHtml = '<span class="text-muted">Not configured</span>';
|
||||
if (bucket.policy_statement_count > 0) {
|
||||
const label = bucket.policy_statement_count === 1 ? '1 statement' : bucket.policy_statement_count + ' statements';
|
||||
policyHtml = '<span class="badge bg-primary">' + label + '<\/span>';
|
||||
}
|
||||
|
||||
const rows = [
|
||||
'<div class="row">',
|
||||
'<div class="col-md-6">',
|
||||
@@ -1341,6 +1653,7 @@ function displayBucketDetails(data) {
|
||||
'<tr><td><strong>Quota:</strong></td><td>' + quotaHtml + '<\/td><\/tr>',
|
||||
'<tr><td><strong>Versioning:</strong></td><td>' + versioningHtml + '<\/td><\/tr>',
|
||||
'<tr><td><strong>Object Lock:</strong></td><td>' + objectLockHtml + '<\/td><\/tr>',
|
||||
'<tr><td><strong>Policy:</strong></td><td>' + policyHtml + '<\/td><\/tr>',
|
||||
'<\/table>',
|
||||
'<\/div>',
|
||||
'<\/div>'
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -150,6 +150,7 @@ templ S3TablesBuckets(data dash.S3TablesBucketsData) {
|
||||
<th>ARN</th>
|
||||
<th>Catalog Endpoint</th>
|
||||
<th>Created</th>
|
||||
<th>Policy</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -164,6 +165,20 @@ templ S3TablesBuckets(data dash.S3TablesBucketsData) {
|
||||
<td class="text-muted small">{ bucket.ARN }</td>
|
||||
<td><code class="small">{ bucketCatalogPath(bucket.Format, bucket.Name) }</code></td>
|
||||
<td>{ bucket.CreatedAt.Format("2006-01-02 15:04") }</td>
|
||||
<td>
|
||||
if bucket.PolicyStatementCount > 0 {
|
||||
<button type="button" class="badge bg-primary border-0 s3tables-bucket-policy-btn"
|
||||
data-bucket-arn={ bucket.ARN } title="View Bucket Policy">
|
||||
if bucket.PolicyStatementCount == 1 {
|
||||
1 statement
|
||||
} else {
|
||||
{ fmt.Sprintf("%d statements", bucket.PolicyStatementCount) }
|
||||
}
|
||||
</button>
|
||||
} else {
|
||||
<span class="text-muted">Not configured</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{{ bucketName, parseErr := s3tables.ParseBucketNameFromARN(bucket.ARN) }}
|
||||
@@ -191,7 +206,7 @@ templ S3TablesBuckets(data dash.S3TablesBucketsData) {
|
||||
}
|
||||
if len(data.Buckets) == 0 {
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
<i class="fas fa-table fa-3x mb-3 text-muted"></i>
|
||||
<div>
|
||||
<h5>No table buckets found</h5>
|
||||
@@ -395,8 +410,10 @@ dataset = lance.dataset(table.location, storage_options=table.storage_options)`
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@PolicyDatalists()
|
||||
|
||||
<div class="modal fade" id="s3tablesBucketPolicyModal" tabindex="-1" aria-labelledby="s3tablesBucketPolicyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="s3tablesBucketPolicyModalLabel">
|
||||
@@ -407,9 +424,24 @@ dataset = lance.dataset(table.location, storage_options=table.storage_options)`
|
||||
<form id="s3tablesBucketPolicyForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="s3tablesBucketPolicyArn" name="bucket_arn"/>
|
||||
<div class="mb-3">
|
||||
<label for="s3tablesBucketPolicyText" class="form-label">Policy JSON</label>
|
||||
<textarea class="form-control" id="s3tablesBucketPolicyText" name="policy" rows="12" placeholder="{ }"></textarea>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="s3tablesBucketPolicyEditorTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#s3tablesBucketPolicyEditorTab" role="tab" aria-controls="s3tablesBucketPolicyEditorTab" aria-selected="true">Editor</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="s3tablesBucketPolicyJsonTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#s3tablesBucketPolicyJsonTab" role="tab" aria-controls="s3tablesBucketPolicyJsonTab" aria-selected="false">JSON</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content border border-top-0 rounded-bottom p-3 mb-3">
|
||||
<div class="tab-pane fade show active" id="s3tablesBucketPolicyEditorTab" role="tabpanel" aria-labelledby="s3tablesBucketPolicyEditorTabBtn">
|
||||
<div id="s3tablesBucketPolicyEditorBody"></div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="s3tablesBucketPolicyAddStatementBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add statement
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="s3tablesBucketPolicyJsonTab" role="tabpanel" aria-labelledby="s3tablesBucketPolicyJsonTabBtn">
|
||||
<textarea class="form-control" id="s3tablesBucketPolicyText" name="policy" rows="12" placeholder="{ }"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Provide a policy JSON; use Delete Policy to remove the policy.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -254,8 +254,10 @@ templ S3TablesTables(data dash.S3TablesTablesData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@PolicyDatalists()
|
||||
|
||||
<div class="modal fade" id="s3tablesTablePolicyModal" tabindex="-1" aria-labelledby="s3tablesTablePolicyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="s3tablesTablePolicyModalLabel">
|
||||
@@ -268,9 +270,24 @@ templ S3TablesTables(data dash.S3TablesTablesData) {
|
||||
<input type="hidden" id="s3tablesTablePolicyBucketArn" name="bucket_arn"/>
|
||||
<input type="hidden" id="s3tablesTablePolicyNamespace" name="namespace"/>
|
||||
<input type="hidden" id="s3tablesTablePolicyName" name="name"/>
|
||||
<div class="mb-3">
|
||||
<label for="s3tablesTablePolicyText" class="form-label">Policy JSON</label>
|
||||
<textarea class="form-control" id="s3tablesTablePolicyText" name="policy" rows="12" placeholder="{ }"></textarea>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="s3tablesTablePolicyEditorTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#s3tablesTablePolicyEditorTab" role="tab" aria-controls="s3tablesTablePolicyEditorTab" aria-selected="true">Editor</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="s3tablesTablePolicyJsonTabBtn" type="button" data-bs-toggle="tab" data-bs-target="#s3tablesTablePolicyJsonTab" role="tab" aria-controls="s3tablesTablePolicyJsonTab" aria-selected="false">JSON</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content border border-top-0 rounded-bottom p-3 mb-3">
|
||||
<div class="tab-pane fade show active" id="s3tablesTablePolicyEditorTab" role="tabpanel" aria-labelledby="s3tablesTablePolicyEditorTabBtn">
|
||||
<div id="s3tablesTablePolicyEditorBody"></div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="s3tablesTablePolicyAddStatementBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add statement
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="s3tablesTablePolicyJsonTab" role="tabpanel" aria-labelledby="s3tablesTablePolicyJsonTabBtn">
|
||||
<textarea class="form-control" id="s3tablesTablePolicyText" name="policy" rows="12" placeholder="{ }"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -370,6 +370,7 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
<script src={ string(view.P("/static/js/admin.js")) }></script>
|
||||
<script src={ string(view.P("/static/js/iam-utils.js")) }></script>
|
||||
<script src={ string(view.P("/static/js/s3tables.js")) }></script>
|
||||
<script src={ string(view.P("/static/js/policy_editor.js")) }></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
@@ -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, "\"></script></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\"></script><script src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var59 string
|
||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(view.P("/static/js/policy_editor.js")))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 373, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\"></script></body></html>")
|
||||
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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
|
||||
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</title><link rel=\"icon\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var61 templ.SafeURL
|
||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinURLErrs(prefix + "/static/favicon.ico")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 386, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 386, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " - Login</title><link rel=\"icon\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var62 templ.SafeURL
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinURLErrs(prefix + "/static/css/bootstrap.min.css")
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinURLErrs(prefix + "/static/favicon.ico")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 388, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 387, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" rel=\"stylesheet\"><link href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var63 templ.SafeURL
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinURLErrs(prefix + "/static/css/fontawesome.min.css")
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinURLErrs(prefix + "/static/css/bootstrap.min.css")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 389, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 389, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" rel=\"stylesheet\"><link href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var64 string
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
var templ_7745c5c3_Var64 templ.SafeURL
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(prefix + "/static/css/fontawesome.min.css")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 399, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 390, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
|
||||
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, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errorMessage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
|
||||
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, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<form method=\"POST\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<form method=\"POST\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var66 templ.SafeURL
|
||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(prefix + "/login"))
|
||||
var templ_7745c5c3_Var67 templ.SafeURL
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(prefix + "/login"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 410, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 411, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var67 string
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 411, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var68 string
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "/static/js/bootstrap.bundle.min.js")
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrfToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 442, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 412, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"></script></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var69 string
|
||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "/static/js/bootstrap.bundle.min.js")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 443, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\"></script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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+"/")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user