Commit Graph
2 Commits
Author SHA1 Message Date
Chris Lu 69cc2869ad Fixes from the review of the admin bucket policy UI (#10907)
* admin: treat a missing S3 Tables policy as an empty load, not an error

The bucket/table policy GET relayed the backend's 404 NoSuchPolicy to the
dialog, whose loader treats any non-OK response as a load failure and
keeps Save and Delete blocked. A bucket or table without a policy could
never be given one. Return policy null instead, the same contract
ShowBucketPolicy uses for classic buckets.

* admin: reject policy documents the structured editor would misread

A top-level JSON array passed the object guard (typeof [] is 'object')
and loaded as a zero-statement policy, which the next commit would
rewrite to an empty document. Object elements in Action/Resource were
coerced to '[object Object]' and saved that way on the s3tables surface,
which stores policies verbatim. Both now throw, which routes the
document to the JSON tab like other unrepresentable shapes.

* admin: let the JSON tab save documents the structured editor can't model

Save with the JSON tab active required a round-trip through
policyDocToEditorState, so exactly the documents the dialogs shunt to
'JSON tab only' mode (unrepresentable Effect, Resource+NotResource, and
the like) could never be saved - Delete was the only mutation left.
Invalid JSON still blocks; an unrepresentable document now saves and the
editor state stays marked unparsed.

* admin: pin the policy editor to what each consumer's backend supports

The s3tables evaluator has no NotResource/NotPrincipal fields - it
silently drops them, turning Allow+NotResource into allow-everything and
making Deny+NotPrincipal inert - and it only matches s3tables: actions
against s3tables ARNs, while the editor suggested s3: actions and
arn:aws:s3::: resources. New registerPolicyEditor knobs: allowNegation
hides the Not* modes and routes documents using them to the JSON tab;
resourceSuggestions pins the Resource autocomplete to the open
resource's ARN; the S3 Tables dialogs get an s3tables-only action
datalist. requirePrincipal now also hides NotPrincipal, which
policy_engine.ValidateBucketPolicy always rejects, and the client-side
check requires Principal specifically to match that server rule.

* admin: save S3 Tables policies from a button, not form submission

The multi-input structured editor sits inside a form whose Save button
was type=submit, so Enter in any single-line editor input - accepting an
autocomplete suggestion, say - implicitly submitted whatever half-built
statement the editor held, and the backend stores the document verbatim.
A lone statement with no Principal matches nobody, locking out every
non-owner. Save is now an ordinary button and the form ignores
submission.

* admin: block zero-statement policy saves

Committing the active tab before the emptiness check made 'Policy JSON
is required' dead code: an empty editor serializes to {"Statement":[]},
which the s3tables backend stores verbatim - evaluated default-deny for
every non-owner, while the statement-count column keeps showing 'Not
configured'. All three policy dialogs now refuse a save with no
statements and point at Delete instead. The classic bucket modal only
gained a clearer message; the server already rejected the document.

* admin: guard S3 Tables policy mutations against stale and overlapping requests

The save/delete completions ran against whatever resource the shared
modal happened to show by then: a slow PUT for one bucket would hide the
modal mid-edit of another and misattribute its alerts, a late DELETE
cleared the shared textarea over the newly opened resource with its
loaded flag set, and nothing stopped a double-click from firing two
overlapping mutations. Ported the classic modal's pattern: capture the
target on start, flag the mutation in flight with the buttons disabled,
and only touch the UI when the completion still matches the open
resource. Success now reloads the page, which also keeps the Policy
column's statement count honest.

* admin: confirm before deleting an S3 Tables policy

Delete Policy sat next to Save and fired on a single click; with
default-allow enabled one stray click silently dropped the resource
policy and left the bucket open to every principal. Same confirmation
the classic bucket modal already has.

* admin: let a corrupt stored bucket policy be shown, fixed, and deleted

A stored document the decoder rejects made the policy GET 500, and with
the loaded flag never set the modal blocked both Save and Delete - the
one policy an operator most needs to remove was the one they couldn't,
even though the delete path never reads the document. The GET now
returns the raw bytes alongside a null policy; the dialog hands them to
the JSON tab and unblocks the buttons.

* admin: url-encode the bucket name in the policy API calls

The filer lists any directory under the buckets path, names S3 would
never allow included; one carrying '#' or '%' broke the fetch URL or
addressed a different name than the modal shows.

* admin: drop stale edit-policy responses on the IAM policies page

The same race the bucket and S3 Tables dialogs already guard against:
open one policy's editor while its GET stalls, open another, and the
late response populates the editor under the second policy's name -
Update then saves the first policy's statements over the second.

* admin: warn before a bucket policy save drops unsupported fields

The editor tracks unmodeled top-level keys precisely so
confirmPolicyFieldDiscard can warn before the server's Version+Statement
decode discards them, but only the IAM page called it; the bucket modal
saved a pasted document with e.g. a console-generated Id without a word
while the editor kept displaying the field.

* s3: enforce the bucket policy size cap on both surfaces

The 20KB cap lived only in the admin UI, so a larger policy stored via
the S3 API displayed there but could never be re-saved, desyncing the
two writers the cap comment claimed could not desync. The constant now
lives in policy_engine next to the shared validator and PutBucketPolicy
rejects oversized documents with PolicyTooLarge, matching AWS.

* admin: ship the policy editor's fieldset styles with the editor

The .policy-stmt-* rules that undo Bootstrap's full-width legend reset
stayed behind in policies.templ when the editor markup moved to the
shared script, so the bucket and S3 Tables dialogs rendered Actions/
Resource/Principal as full-width jumbo headings. PolicyDatalists is the
component every consumer already renders once; the styles live there
now.

* s3: mirror bucket policy changes into the IAM store from the metadata subscription

The advanced-IAM path appends the bucket-policy:<bucket> document to
every STS/session evaluation, but only this gateway's own PutBucketPolicy
maintained that mirror - a policy tightened or created through the admin
UI (or another gateway) never reached it, so revoked access stayed live
indefinitely, and the delete side was an unimplemented TODO in any case.
The metadata subscription now diffs the stored policy on every bucket
entry change and updates or removes the mirror, covering all writers and
deletion with one mechanism; IAMManager gains the missing
RemoveBucketPolicy.

* admin: deduplicate the bucket policy write path

Set and Delete carried line-for-line identical filer closures;
bucketPolicyMutation already treats nil as clear-the-key. The shared
helper sits below Set's validation, since ValidatePolicy cannot take the
nil document Delete passes.

* s3: drop ValidateBucketPolicy's re-checks of ValidatePolicy rules

Both callers run ValidatePolicy first, which already enforces the
version and at-least-one-statement rules; the duplicates were dead code
with drifted error text.

* admin: seed a new statement's Resource from the pinned suggestions

A fresh statement on the S3 Tables dialogs started with no resource row
at all; seed it with the broadest pinned ARN the same way cfg.bucket
already seeds the classic modal.

* admin: refuse to save Not* fields the backend would silently drop

Hiding the NotResource/NotPrincipal modes was not enough where negation
is disallowed: the JSON tab accepts any valid document (that is its
job), and a statement's Advanced-fields box can reintroduce the keys, so
an s3tables save could still store fields the evaluator drops - turning
Allow+NotResource into allow-everything. commitPolicyActiveTab now runs
a final document-level check over what would actually be saved; Delete
stays available for cleanup.

* s3: move the IAM bucket policy mirror on a bucket rename

A same-directory rename delivers one event carrying both entries, and
the byte-equality short-circuit skipped the new name's mirror when the
policy was unchanged - while the replayed delete for the old name
removed its mirror, leaving the renamed bucket unmirrored. The mirror
decision is now a pure function that removes the old name and writes the
new one regardless of byte equality, with the rename cases unit tested.

* s3: backfill the IAM bucket policy mirror on lazy bucket loads

The metadata subscription only mirrors changes, so a policy that
predates the IAM integration never reached the bucket-policy:<bucket>
mirror and its grants did not bind on the IAM path until the policy was
next modified. The gateway is deliberately lazy at startup (nothing
lists all buckets), so the backfill hooks the same place a bucket's
policy first becomes known: the cold bucket-config load. EnsureBucketPolicy
writes only when no mirror is stored, so repeat loads cost one cached
read.

* s3: reconcile the bucket policy backfill against concurrent changes

The backfill's check-then-write could race an event-driven mirror update
or removal and re-store bytes that were already stale, with no later
event to heal it. EnsureBucketPolicy now reports whether it wrote, and a
write is reconciled against a fresh authoritative entry read: a changed
policy is re-mirrored, a removed one is removed. Anything changing after
that read fires its own event, which finds the backfill's write already
present and supersedes it. The backfill also carries the entry's raw
bytes rather than a re-marshaled document, so the reconcile can
byte-compare.

* s3: prime the bucket policy mirror before advanced-IAM authorization

The backfill ran from the lazy bucket-config load, but IAM authorization
evaluates the bucket-policy:<bucket> mirror before any handler runs - a
grant carried only by a not-yet-mirrored policy denied forever, and the
denied request never reached the code that would have loaded the bucket.
authorizeWithIAM now primes the bucket config first (an in-memory cache
hit once warm), and the backfill runs synchronously on the cold load so
the very first authorization already sees the mirror.
2026-08-24 00:52:01 -07:00
Mathieu Arnold e931cccc7b 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
2026-08-23 22:11:18 -07:00