mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-28 03:46:24 +00:00
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.
This commit is contained in:
@@ -267,16 +267,22 @@ func (s *AdminServer) ShowBucketPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := s.GetBucketPolicy(bucketName)
|
||||
policy, raw, 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{}{
|
||||
resp := map[string]interface{}{
|
||||
"bucket": bucketName,
|
||||
"policy": policy,
|
||||
})
|
||||
}
|
||||
if policy == nil && len(raw) > 0 {
|
||||
// Stored bytes the decoder rejects: hand them to the JSON tab so
|
||||
// the operator can fix or delete the document.
|
||||
resp["policy_text"] = string(raw)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdateBucketPolicy replaces the bucket policy for a bucket.
|
||||
|
||||
@@ -12,28 +12,26 @@ import (
|
||||
"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) {
|
||||
// entry, or (nil, 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. Stored bytes the current decoder
|
||||
// rejects come back as (nil, raw, nil): a 500 here would leave the UI
|
||||
// unable to show, fix, or even delete the one policy an operator most
|
||||
// needs to remove — and DeleteBucketPolicy never reads the document.
|
||||
func (s *AdminServer) GetBucketPolicy(bucketName string) (*policy_engine.PolicyDocument, []byte, error) {
|
||||
filerConfig, err := s.getFilerConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get filer configuration: %w", err)
|
||||
return nil, nil, fmt.Errorf("get filer configuration: %w", err)
|
||||
}
|
||||
|
||||
var doc *policy_engine.PolicyDocument
|
||||
var raw []byte
|
||||
err = s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: filerConfig.BucketsPath,
|
||||
@@ -50,19 +48,19 @@ func (s *AdminServer) GetBucketPolicy(bucketName string) (*policy_engine.PolicyD
|
||||
if len(policyJSON) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw = policyJSON
|
||||
|
||||
var parsed policy_engine.PolicyDocument
|
||||
if err := json.Unmarshal(policyJSON, &parsed); err != nil {
|
||||
return fmt.Errorf("parse stored bucket policy: %w", err)
|
||||
if err := json.Unmarshal(policyJSON, &parsed); err == nil {
|
||||
doc = &parsed
|
||||
}
|
||||
doc = &parsed
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
return doc, raw, nil
|
||||
}
|
||||
|
||||
// SetBucketPolicy validates and stores a bucket policy, applying the exact
|
||||
@@ -72,18 +70,11 @@ func (s *AdminServer) GetBucketPolicy(bucketName string) (*policy_engine.PolicyD
|
||||
//
|
||||
// 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.
|
||||
// each gateway's onBucketMetadataChange subscription watches to rebuild its
|
||||
// bucket policy cache and to maintain the advanced-IAM
|
||||
// "bucket-policy:<bucket>" mirror (mirrorBucketPolicyToIAM in
|
||||
// weed/s3api/s3api_bucket_policy_handlers.go). No separate notify step is
|
||||
// needed here.
|
||||
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)
|
||||
@@ -96,10 +87,24 @@ func (s *AdminServer) SetBucketPolicy(bucketName string, doc *policy_engine.Poli
|
||||
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)
|
||||
if len(policyJSON) > policy_engine.MaxBucketPolicySize {
|
||||
return fmt.Errorf("%w: bucket policy is %d bytes, which exceeds the %d byte limit", ErrInvalidBucketPolicy, len(policyJSON), policy_engine.MaxBucketPolicySize)
|
||||
}
|
||||
|
||||
return s.writeBucketPolicy(bucketName, policyJSON)
|
||||
}
|
||||
|
||||
// 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. This cannot go through
|
||||
// SetBucketPolicy: validation there rejects a nil document.
|
||||
func (s *AdminServer) DeleteBucketPolicy(bucketName string) error {
|
||||
return s.writeBucketPolicy(bucketName, nil)
|
||||
}
|
||||
|
||||
// writeBucketPolicy patches the policy key on the bucket's filer entry; a
|
||||
// nil policyJSON clears it.
|
||||
func (s *AdminServer) writeBucketPolicy(bucketName string, policyJSON []byte) error {
|
||||
filerConfig, err := s.getFilerConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get filer configuration: %w", err)
|
||||
@@ -125,46 +130,10 @@ func (s *AdminServer) SetBucketPolicy(bucketName string, doc *policy_engine.Poli
|
||||
Mutations: []*filer_pb.ObjectMutation{bucketPolicyMutation(filerConfig.BucketsPath, bucketName, policyJSON)},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update bucket policy: %w", err)
|
||||
return fmt.Errorf("write 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 fmt.Errorf("write bucket policy: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -128,7 +128,7 @@ 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)
|
||||
doc.Statement[0].Sid = strings.Repeat("x", policy_engine.MaxBucketPolicySize+1)
|
||||
|
||||
err := (&AdminServer{}).SetBucketPolicy("mybucket", doc)
|
||||
if err == nil {
|
||||
|
||||
@@ -1043,8 +1043,11 @@ func (s *AdminServer) GetS3TablesBucketPolicy(w http.ResponseWriter, r *http.Req
|
||||
getReq := &s3tables.GetTableBucketPolicyRequest{TableBucketARN: bucketArn}
|
||||
var resp s3tables.GetTableBucketPolicyResponse
|
||||
if err := s.executeS3TablesOperation(r.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil {
|
||||
writeS3TablesError(w, err)
|
||||
return
|
||||
// No policy is a normal state for the UI (empty editor), not an error.
|
||||
if !isS3TablesNoSuchPolicy(err) {
|
||||
writeS3TablesError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy})
|
||||
}
|
||||
@@ -1111,8 +1114,10 @@ func (s *AdminServer) GetS3TablesTablePolicy(w http.ResponseWriter, r *http.Requ
|
||||
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
|
||||
var resp s3tables.GetTablePolicyResponse
|
||||
if err := s.executeS3TablesOperation(r.Context(), "GetTablePolicy", getReq, &resp); err != nil {
|
||||
writeS3TablesError(w, err)
|
||||
return
|
||||
if !isS3TablesNoSuchPolicy(err) {
|
||||
writeS3TablesError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy})
|
||||
}
|
||||
@@ -1225,6 +1230,11 @@ func writeS3TablesError(w http.ResponseWriter, err error) {
|
||||
writeJSONError(w, s3TablesErrorStatus(err), parseS3TablesErrorMessage(err))
|
||||
}
|
||||
|
||||
func isS3TablesNoSuchPolicy(err error) bool {
|
||||
var s3Err *s3tables.S3TablesError
|
||||
return errors.As(err, &s3Err) && s3Err.Type == s3tables.ErrCodeNoSuchPolicy
|
||||
}
|
||||
|
||||
func s3TablesErrorStatus(err error) int {
|
||||
var s3Err *s3tables.S3TablesError
|
||||
if errors.As(err, &s3Err) {
|
||||
|
||||
@@ -35,15 +35,24 @@ const POLICY_EDITOR_CONFIG = {};
|
||||
// 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.
|
||||
// instead of leaving it empty, and the NotPrincipal
|
||||
// mode is not offered (the bucket policy backend
|
||||
// rejects statements without a Principal). The
|
||||
// server remains the source of truth for this rule
|
||||
// either way - see policy_engine.ValidateBucketPolicy.
|
||||
// allowNegation - if false, the NotResource/NotPrincipal mode
|
||||
// dropdowns are not offered and a loaded document
|
||||
// using them falls back to the JSON tab. For
|
||||
// backends whose evaluator has no Not* fields
|
||||
// (s3tables silently drops them, turning e.g.
|
||||
// Allow+NotResource into allow-everything).
|
||||
// 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>/*.
|
||||
// resourceSuggestions - if set, the Resource autocomplete offers exactly
|
||||
// these values and never fetches bucket/folder
|
||||
// names. For non-S3 ARN dialects (s3tables).
|
||||
function registerPolicyEditor(which, config) {
|
||||
POLICY_EDITOR_CONFIG[which] = Object.assign({
|
||||
textareaId: which + 'PolicyDocument',
|
||||
@@ -55,7 +64,9 @@ function registerPolicyEditor(which, config) {
|
||||
resourceDatalistId: 'policyResourceSuggestions',
|
||||
principalDatalistId: 'policyPrincipalSuggestions',
|
||||
requirePrincipal: false,
|
||||
bucket: null
|
||||
allowNegation: true,
|
||||
bucket: null,
|
||||
resourceSuggestions: null
|
||||
}, config || {});
|
||||
}
|
||||
|
||||
@@ -104,16 +115,43 @@ function policyEditorConfig(which) {
|
||||
return policyEditorConfig(which).textareaId;
|
||||
}
|
||||
|
||||
function policyEditorOffersNotPrincipal(which) {
|
||||
const cfg = policyEditorConfig(which);
|
||||
return cfg.allowNegation && !cfg.requirePrincipal;
|
||||
}
|
||||
|
||||
// True when the serialized policy carries at least one statement. Guards
|
||||
// Save in the consumers: an empty editor commits as {"Statement":[]},
|
||||
// which is never what a user wants stored - on backends with no
|
||||
// server-side validation (s3tables) it evaluates default-deny for every
|
||||
// non-owner while the UI still shows "Not configured".
|
||||
function policyTextHasStatements(text) {
|
||||
try {
|
||||
const stmts = (JSON.parse(text) || {}).Statement;
|
||||
return Array.isArray(stmts) ? stmts.length > 0 : !!stmts;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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 values = Array.isArray(value) ? value : [value];
|
||||
return values.map(function(v) {
|
||||
if (v !== null && typeof v === 'object') {
|
||||
// String(v) would render '[object Object]' (or comma-join a
|
||||
// nested array) and a later save would persist that coercion;
|
||||
// send the document to the JSON tab instead.
|
||||
throw new Error('Policy list entries must be strings (got ' + JSON.stringify(v) + ')');
|
||||
}
|
||||
// Coerce scalars: these feed escapeHtml, which calls text.replace,
|
||||
// and a policy is free to carry a number or a boolean here.
|
||||
return String(v);
|
||||
});
|
||||
}
|
||||
|
||||
const POLICY_DOCUMENT_KNOWN_KEYS = ['Version', 'Statement'];
|
||||
@@ -126,21 +164,21 @@ function policyEditorConfig(which) {
|
||||
// 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
|
||||
function policyDocToEditorState(which, doc) {
|
||||
const cfg = policyEditorConfig(which);
|
||||
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
||||
// null, a bare scalar, or an array (typeof [] is 'object', and a
|
||||
// pasted statement array is a common mistake) 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];
|
||||
}
|
||||
});
|
||||
}
|
||||
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])
|
||||
: [];
|
||||
@@ -161,6 +199,9 @@ function policyEditorConfig(which) {
|
||||
// it in the JSON tab rather than silently picking one.
|
||||
throw new Error('Statement ' + (idx + 1) + ': cannot specify both Resource and NotResource');
|
||||
}
|
||||
if (hasNotResource && !cfg.allowNegation) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': NotResource is not supported for this policy type');
|
||||
}
|
||||
const resourceMode = hasNotResource ? 'NotResource' : 'Resource';
|
||||
|
||||
const hasPrincipal = Object.prototype.hasOwnProperty.call(stmt, 'Principal');
|
||||
@@ -168,6 +209,9 @@ function policyEditorConfig(which) {
|
||||
if (hasPrincipal && hasNotPrincipal) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': cannot specify both Principal and NotPrincipal');
|
||||
}
|
||||
if (hasNotPrincipal && (!cfg.allowNegation || cfg.requirePrincipal)) {
|
||||
throw new Error('Statement ' + (idx + 1) + ': NotPrincipal is not supported for this policy type');
|
||||
}
|
||||
let principalMode = 'Principal';
|
||||
let principalValues = [];
|
||||
let principalManaged = false;
|
||||
@@ -221,7 +265,12 @@ function policyEditorConfig(which) {
|
||||
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);
|
||||
try {
|
||||
return normalizeToStringArray(value.AWS);
|
||||
} catch (e) {
|
||||
// Non-string entries: not the simple form; fall back to extras.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Converts editor state back into a policy document. Structured fields
|
||||
@@ -365,24 +414,28 @@ function policyEditorConfig(which) {
|
||||
'<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 class="policy-stmt-legend' + (policyEditorConfig(which).allowNegation ? '' : ' border rounded') + '">' +
|
||||
(policyEditorConfig(which).allowNegation
|
||||
? '<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>'
|
||||
: 'Resource') +
|
||||
'</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 class="policy-stmt-legend' + (policyEditorOffersNotPrincipal(which) ? '' : ' border rounded') + '">' +
|
||||
(policyEditorOffersNotPrincipal(which)
|
||||
? '<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>'
|
||||
: 'Principal') +
|
||||
'</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>' +
|
||||
'<div class="form-text mt-0 mb-1">' + (policyEditorOffersNotPrincipal(which) ? 'Principal / NotPrincipal' : 'Principal') + ' (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>' +
|
||||
@@ -474,7 +527,7 @@ function policyEditorConfig(which) {
|
||||
}
|
||||
let newState;
|
||||
try {
|
||||
newState = policyDocToEditorState(doc);
|
||||
newState = policyDocToEditorState(which, doc);
|
||||
} catch (e) {
|
||||
showAlert(e.message, 'error');
|
||||
return false;
|
||||
@@ -522,7 +575,7 @@ function policyEditorConfig(which) {
|
||||
}
|
||||
let state;
|
||||
try {
|
||||
state = policyDocToEditorState(doc);
|
||||
state = policyDocToEditorState(which, doc);
|
||||
} catch (e) {
|
||||
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {}, unparsed: true };
|
||||
renderPolicyEditor(which);
|
||||
@@ -543,9 +596,13 @@ function policyEditorConfig(which) {
|
||||
}
|
||||
const cfg = policyEditorConfig(which);
|
||||
commitPolicyEditorForm(which);
|
||||
// Seed Resource with the broadest pinned suggestion, mirroring the
|
||||
// arn:aws:s3::: seeding cfg.bucket gets.
|
||||
const seededResources = cfg.bucket ? ['arn:aws:s3:::' + cfg.bucket + '/*']
|
||||
: (cfg.resourceSuggestions ? [cfg.resourceSuggestions[cfg.resourceSuggestions.length - 1]] : []);
|
||||
policyEditorState(which).statements.push({
|
||||
sid: '', effect: 'Allow', actions: [],
|
||||
resourceMode: 'Resource', resources: cfg.bucket ? ['arn:aws:s3:::' + cfg.bucket + '/*'] : [],
|
||||
resourceMode: 'Resource', resources: seededResources,
|
||||
principalMode: 'Principal', principalValues: cfg.requirePrincipal ? ['*'] : [], hasComplexPrincipal: false,
|
||||
extras: ''
|
||||
});
|
||||
@@ -569,21 +626,74 @@ function policyEditorConfig(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);
|
||||
const text = document.getElementById(policyTextareaId(which)).value;
|
||||
if (text && text.trim()) {
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(text);
|
||||
} catch (e) {
|
||||
showAlert('Invalid JSON in policy document: ' + e.message, 'error');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
policyEditors[which] = policyDocToEditorState(which, doc);
|
||||
} catch (e) {
|
||||
// Valid JSON the structured editor can't model is still
|
||||
// saveable from here - exactly the documents the load path
|
||||
// shunts to this tab. It just stays JSON-tab-only.
|
||||
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {}, unparsed: true };
|
||||
}
|
||||
renderPolicyEditor(which);
|
||||
} else if (!commitPolicyTextareaToEditor(which)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
} catch (e) {
|
||||
showAlert(e.message, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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');
|
||||
// Final gate over what would actually be saved. The mode dropdowns
|
||||
// being hidden is not enough: the JSON tab and a statement's
|
||||
// Advanced-fields box can both carry Not* keys, and where negation
|
||||
// is disallowed the backend's evaluator silently drops them -
|
||||
// Allow+NotResource would come back as allow-everything.
|
||||
const negationError = policyTextDisallowedNegationError(which);
|
||||
if (negationError) {
|
||||
showAlert(negationError, 'error');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns an error message if the JSON textarea for `which` holds a
|
||||
// statement using NotResource/NotPrincipal while the instance disallows
|
||||
// negation, or null. Unparseable/empty text is left to other checks.
|
||||
function policyTextDisallowedNegationError(which) {
|
||||
if (policyEditorConfig(which).allowNegation) return null;
|
||||
let doc;
|
||||
try {
|
||||
commitPolicyEditorToTextarea(which);
|
||||
return true;
|
||||
doc = JSON.parse(document.getElementById(policyTextareaId(which)).value);
|
||||
} catch (e) {
|
||||
showAlert(e.message, 'error');
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const stmts = doc && doc.Statement ? (Array.isArray(doc.Statement) ? doc.Statement : [doc.Statement]) : [];
|
||||
for (let i = 0; i < stmts.length; i++) {
|
||||
const stmt = stmts[i] || {};
|
||||
if (Object.prototype.hasOwnProperty.call(stmt, 'NotResource') ||
|
||||
Object.prototype.hasOwnProperty.call(stmt, 'NotPrincipal')) {
|
||||
return 'Statement ' + (i + 1) + ': NotResource/NotPrincipal are not supported for this policy type and would be silently ignored. Remove them before saving.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The admin API's policy document carries only Version and Statement, so
|
||||
@@ -609,8 +719,11 @@ function policyEditorConfig(which) {
|
||||
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.';
|
||||
// Principal specifically: the server rule this front-runs
|
||||
// (policy_engine.ValidateBucketPolicy) rejects NotPrincipal-only
|
||||
// statements too.
|
||||
if (stmt.Principal === undefined) {
|
||||
return 'Statement ' + (i + 1) + ': a Principal is required.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -805,6 +918,10 @@ function policyEditorConfig(which) {
|
||||
const cfg = policyEditorConfig(which);
|
||||
const datalist = document.getElementById(cfg.resourceDatalistId);
|
||||
if (!datalist) return;
|
||||
if (cfg.resourceSuggestions) {
|
||||
renderPolicyDatalistOptions(datalist, cfg.resourceSuggestions);
|
||||
return;
|
||||
}
|
||||
const state = policyResourcePathState(inputEl.value);
|
||||
|
||||
if (state.stage === 'bucket') {
|
||||
@@ -880,7 +997,7 @@ function policyEditorConfig(which) {
|
||||
|
||||
function insertSamplePolicy(which, sampleDoc) {
|
||||
const doc = sampleDoc || POLICY_SAMPLE_DOCUMENT;
|
||||
policyEditors[which] = policyDocToEditorState(doc);
|
||||
policyEditors[which] = policyDocToEditorState(which, doc);
|
||||
renderPolicyEditor(which);
|
||||
document.getElementById(policyTextareaId(which)).value = JSON.stringify(doc, null, 2);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,41 @@ let s3tablesTablePolicyLoaded = false;
|
||||
let s3tablesBucketPolicyRequestSeq = 0;
|
||||
let s3tablesTablePolicyRequestSeq = 0;
|
||||
|
||||
// True while a policy PUT/DELETE is in flight, so a double-click - or Save
|
||||
// and Delete fired in quick succession - can't send overlapping mutations.
|
||||
// Same pattern as the classic bucket modal in s3_buckets.templ.
|
||||
let s3tablesBucketPolicyMutationInFlight = false;
|
||||
let s3tablesTablePolicyMutationInFlight = false;
|
||||
|
||||
function setS3TablesBucketPolicyMutationInFlight(inFlight) {
|
||||
s3tablesBucketPolicyMutationInFlight = inFlight;
|
||||
const save = document.getElementById('s3tablesBucketPolicySaveBtn');
|
||||
const del = document.getElementById('s3tablesBucketPolicyDeleteBtn');
|
||||
if (save) save.disabled = inFlight;
|
||||
if (del) del.disabled = inFlight;
|
||||
}
|
||||
|
||||
function setS3TablesTablePolicyMutationInFlight(inFlight) {
|
||||
s3tablesTablePolicyMutationInFlight = inFlight;
|
||||
const save = document.getElementById('s3tablesTablePolicySaveBtn');
|
||||
const del = document.getElementById('s3tablesTablePolicyDeleteBtn');
|
||||
if (save) save.disabled = inFlight;
|
||||
if (del) del.disabled = inFlight;
|
||||
}
|
||||
|
||||
// The dialog identity captured when a mutation started, so a completion
|
||||
// that lands after the shared modal moved on to a different resource can't
|
||||
// alert against, hide, or reload over that other resource's state.
|
||||
function currentS3TablesBucketPolicyTarget() {
|
||||
return document.getElementById('s3tablesBucketPolicyArn').value;
|
||||
}
|
||||
|
||||
function currentS3TablesTablePolicyTarget() {
|
||||
return document.getElementById('s3tablesTablePolicyBucketArn').value + '\n' +
|
||||
document.getElementById('s3tablesTablePolicyNamespace').value + '\n' +
|
||||
document.getElementById('s3tablesTablePolicyName').value;
|
||||
}
|
||||
|
||||
function getCSRFToken() {
|
||||
const tokenMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!tokenMeta) {
|
||||
@@ -57,13 +92,7 @@ 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' });
|
||||
registerS3TablesBucketPolicyEditor('');
|
||||
setupPolicyEditor('s3tablesBucket');
|
||||
|
||||
const ownerSelect = document.getElementById('s3tablesBucketOwner');
|
||||
@@ -107,6 +136,7 @@ function initS3TablesBuckets() {
|
||||
button.addEventListener('click', function () {
|
||||
const bucketArn = this.dataset.bucketArn || '';
|
||||
document.getElementById('s3tablesBucketPolicyArn').value = bucketArn;
|
||||
registerS3TablesBucketPolicyEditor(bucketArn);
|
||||
loadS3TablesBucketPolicy(bucketArn);
|
||||
s3tablesBucketPolicyModal.show();
|
||||
});
|
||||
@@ -203,36 +233,11 @@ function initS3TablesBuckets() {
|
||||
|
||||
const policyForm = document.getElementById('s3tablesBucketPolicyForm');
|
||||
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) {
|
||||
alert('Policy JSON is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(s3tBasePath('/api/s3tables/bucket-policy'), {
|
||||
method: 'PUT',
|
||||
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ bucket_arn: bucketArn, policy: policy })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
alert(data.error || 'Failed to update policy');
|
||||
return;
|
||||
}
|
||||
alert('Policy updated');
|
||||
s3tablesBucketPolicyModal.hide();
|
||||
} catch (error) {
|
||||
alert('Failed to update policy: ' + error.message);
|
||||
}
|
||||
});
|
||||
// Saves go through the Save button only; implicit form submission
|
||||
// (Enter in a single-line editor input) must never PUT half-built
|
||||
// state, which the backend would store verbatim.
|
||||
policyForm.addEventListener('submit', function (e) { e.preventDefault(); });
|
||||
document.getElementById('s3tablesBucketPolicySaveBtn').addEventListener('click', saveS3TablesBucketPolicy);
|
||||
}
|
||||
|
||||
const tagsForm = document.getElementById('s3tablesTagsForm');
|
||||
@@ -258,7 +263,7 @@ function initS3TablesTables() {
|
||||
s3tablesTablePolicyModal = new bootstrap.Modal(document.getElementById('s3tablesTablePolicyModal'));
|
||||
s3tablesTagsModal = new bootstrap.Modal(document.getElementById('s3tablesTagsModal'));
|
||||
|
||||
registerPolicyEditor('s3tablesTable', { textareaId: 's3tablesTablePolicyText' });
|
||||
registerS3TablesTablePolicyEditor('', '', '');
|
||||
setupPolicyEditor('s3tablesTable');
|
||||
|
||||
const dataContainer = document.getElementById('s3tables-tables-content');
|
||||
@@ -278,6 +283,7 @@ function initS3TablesTables() {
|
||||
document.getElementById('s3tablesTablePolicyBucketArn').value = dataBucketArn;
|
||||
document.getElementById('s3tablesTablePolicyNamespace').value = dataNamespace;
|
||||
document.getElementById('s3tablesTablePolicyName').value = this.dataset.tableName || '';
|
||||
registerS3TablesTablePolicyEditor(dataBucketArn, dataNamespace, this.dataset.tableName || '');
|
||||
loadS3TablesTablePolicy(dataBucketArn, dataNamespace, this.dataset.tableName || '');
|
||||
s3tablesTablePolicyModal.show();
|
||||
});
|
||||
@@ -346,35 +352,9 @@ function initS3TablesTables() {
|
||||
|
||||
const policyForm = document.getElementById('s3tablesTablePolicyForm');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(s3tBasePath('/api/s3tables/table-policy'), {
|
||||
method: 'PUT',
|
||||
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ bucket_arn: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value, policy: policy })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
alert(data.error || 'Failed to update policy');
|
||||
return;
|
||||
}
|
||||
alert('Policy updated');
|
||||
s3tablesTablePolicyModal.hide();
|
||||
} catch (error) {
|
||||
alert('Failed to update policy: ' + error.message);
|
||||
}
|
||||
});
|
||||
// Same Enter-must-not-submit rule as the bucket policy form.
|
||||
policyForm.addEventListener('submit', function (e) { e.preventDefault(); });
|
||||
document.getElementById('s3tablesTablePolicySaveBtn').addEventListener('click', saveS3TablesTablePolicy);
|
||||
}
|
||||
|
||||
const tagsForm = document.getElementById('s3tablesTagsForm');
|
||||
@@ -619,6 +599,30 @@ async function deleteS3TablesBucket() {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared visual policy editor (weed/admin/static/js/policy_editor.js),
|
||||
// configured for the S3 Tables policy engine: only s3tables: action
|
||||
// suggestions, resource suggestions pinned to the open resource's ARN, and
|
||||
// no NotResource/NotPrincipal modes - the s3tables evaluator has no such
|
||||
// fields and would silently drop them (see s3tables/permissions.go).
|
||||
// Re-registered on every dialog open so the suggestions track the resource.
|
||||
function registerS3TablesBucketPolicyEditor(bucketArn) {
|
||||
registerPolicyEditor('s3tablesBucket', {
|
||||
textareaId: 's3tablesBucketPolicyText',
|
||||
actionDatalistId: 's3tablesPolicyActionSuggestions',
|
||||
allowNegation: false,
|
||||
resourceSuggestions: bucketArn ? [bucketArn, bucketArn + '/table/*'] : null
|
||||
});
|
||||
}
|
||||
|
||||
function registerS3TablesTablePolicyEditor(bucketArn, namespace, name) {
|
||||
registerPolicyEditor('s3tablesTable', {
|
||||
textareaId: 's3tablesTablePolicyText',
|
||||
actionDatalistId: 's3tablesPolicyActionSuggestions',
|
||||
allowNegation: false,
|
||||
resourceSuggestions: bucketArn && namespace && name ? [bucketArn + '/table/' + namespace + '/' + name] : null
|
||||
});
|
||||
}
|
||||
|
||||
async function loadS3TablesBucketPolicy(bucketArn) {
|
||||
const requestSeq = ++s3tablesBucketPolicyRequestSeq;
|
||||
document.getElementById('s3tablesBucketPolicyText').value = '';
|
||||
@@ -658,25 +662,133 @@ async function loadS3TablesBucketPolicy(bucketArn) {
|
||||
loadPolicyTextareaIntoEditor('s3tablesBucket');
|
||||
}
|
||||
|
||||
async function saveS3TablesBucketPolicy() {
|
||||
if (s3tablesBucketPolicyMutationInFlight) return;
|
||||
if (!s3tablesBucketPolicyLoaded) {
|
||||
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
|
||||
return;
|
||||
}
|
||||
if (!commitPolicyActiveTab('s3tablesBucket')) return;
|
||||
const bucketArn = currentS3TablesBucketPolicyTarget();
|
||||
const policy = document.getElementById('s3tablesBucketPolicyText').value.trim();
|
||||
if (!policy || !policyTextHasStatements(policy)) {
|
||||
alert('Add at least one statement, or use Delete Policy to remove the policy.');
|
||||
return;
|
||||
}
|
||||
setS3TablesBucketPolicyMutationInFlight(true);
|
||||
try {
|
||||
const response = await fetch(s3tBasePath('/api/s3tables/bucket-policy'), {
|
||||
method: 'PUT',
|
||||
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ bucket_arn: bucketArn, policy: policy })
|
||||
});
|
||||
const data = await response.json();
|
||||
setS3TablesBucketPolicyMutationInFlight(false);
|
||||
const stillCurrent = bucketArn === currentS3TablesBucketPolicyTarget();
|
||||
if (!response.ok) {
|
||||
if (stillCurrent) {
|
||||
alert(data.error || 'Failed to update policy');
|
||||
} else {
|
||||
console.error('Error saving policy for ' + bucketArn + ' (no longer the open resource): ' + (data.error || 'unknown error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!stillCurrent) return;
|
||||
s3tablesBucketPolicyModal.hide();
|
||||
// Reload so the Policy column reflects the change.
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} catch (error) {
|
||||
setS3TablesBucketPolicyMutationInFlight(false);
|
||||
if (bucketArn === currentS3TablesBucketPolicyTarget()) {
|
||||
alert('Failed to update policy: ' + error.message);
|
||||
} else {
|
||||
console.error('Error saving policy for ' + bucketArn + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveS3TablesTablePolicy() {
|
||||
if (s3tablesTablePolicyMutationInFlight) return;
|
||||
if (!s3tablesTablePolicyLoaded) {
|
||||
alert('The current policy has not finished loading. Close and reopen this dialog before saving.');
|
||||
return;
|
||||
}
|
||||
if (!commitPolicyActiveTab('s3tablesTable')) return;
|
||||
const target = currentS3TablesTablePolicyTarget();
|
||||
const policy = document.getElementById('s3tablesTablePolicyText').value.trim();
|
||||
if (!policy || !policyTextHasStatements(policy)) {
|
||||
alert('Add at least one statement, or use Delete Policy to remove the policy.');
|
||||
return;
|
||||
}
|
||||
setS3TablesTablePolicyMutationInFlight(true);
|
||||
try {
|
||||
const response = await fetch(s3tBasePath('/api/s3tables/table-policy'), {
|
||||
method: 'PUT',
|
||||
headers: s3tWriteHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
bucket_arn: document.getElementById('s3tablesTablePolicyBucketArn').value,
|
||||
namespace: document.getElementById('s3tablesTablePolicyNamespace').value,
|
||||
name: document.getElementById('s3tablesTablePolicyName').value,
|
||||
policy: policy
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
setS3TablesTablePolicyMutationInFlight(false);
|
||||
const stillCurrent = target === currentS3TablesTablePolicyTarget();
|
||||
if (!response.ok) {
|
||||
if (stillCurrent) {
|
||||
alert(data.error || 'Failed to update policy');
|
||||
} else {
|
||||
console.error('Error saving table policy (no longer the open resource): ' + (data.error || 'unknown error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!stillCurrent) return;
|
||||
s3tablesTablePolicyModal.hide();
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} catch (error) {
|
||||
setS3TablesTablePolicyMutationInFlight(false);
|
||||
if (target === currentS3TablesTablePolicyTarget()) {
|
||||
alert('Failed to update policy: ' + error.message);
|
||||
} else {
|
||||
console.error('Error saving table policy: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteS3TablesBucketPolicy() {
|
||||
const bucketArn = document.getElementById('s3tablesBucketPolicyArn').value;
|
||||
const bucketArn = currentS3TablesBucketPolicyTarget();
|
||||
if (!bucketArn) return;
|
||||
if (s3tablesBucketPolicyMutationInFlight) return;
|
||||
if (!s3tablesBucketPolicyLoaded) {
|
||||
alert('The current policy has not finished loading. Close and reopen this dialog before deleting.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Delete this table bucket policy? This cannot be undone.')) return;
|
||||
setS3TablesBucketPolicyMutationInFlight(true);
|
||||
try {
|
||||
const response = await fetch(s3tBasePath(`/api/s3tables/bucket-policy?bucket=${encodeURIComponent(bucketArn)}`), { method: 'DELETE', headers: s3tWriteHeaders() });
|
||||
const data = await response.json();
|
||||
setS3TablesBucketPolicyMutationInFlight(false);
|
||||
const stillCurrent = bucketArn === currentS3TablesBucketPolicyTarget();
|
||||
if (!response.ok) {
|
||||
alert(data.error || 'Failed to delete policy');
|
||||
if (stillCurrent) {
|
||||
alert(data.error || 'Failed to delete policy');
|
||||
} else {
|
||||
console.error('Error deleting policy for ' + bucketArn + ' (no longer the open resource): ' + (data.error || 'unknown error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
alert('Policy deleted');
|
||||
document.getElementById('s3tablesBucketPolicyText').value = '';
|
||||
commitPolicyTextareaToEditor('s3tablesBucket');
|
||||
if (!stillCurrent) return;
|
||||
s3tablesBucketPolicyModal.hide();
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} catch (error) {
|
||||
alert('Failed to delete policy: ' + error.message);
|
||||
setS3TablesBucketPolicyMutationInFlight(false);
|
||||
if (bucketArn === currentS3TablesBucketPolicyTarget()) {
|
||||
alert('Failed to delete policy: ' + error.message);
|
||||
} else {
|
||||
console.error('Error deleting policy for ' + bucketArn + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,26 +898,42 @@ async function loadS3TablesTablePolicy(bucketArn, namespace, name) {
|
||||
}
|
||||
|
||||
async function deleteS3TablesTablePolicy() {
|
||||
if (s3tablesTablePolicyMutationInFlight) return;
|
||||
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 || '';
|
||||
const query = new URLSearchParams({ bucket: dataBucketArn, namespace: dataNamespace, name: document.getElementById('s3tablesTablePolicyName').value });
|
||||
const target = currentS3TablesTablePolicyTarget();
|
||||
const query = new URLSearchParams({
|
||||
bucket: document.getElementById('s3tablesTablePolicyBucketArn').value,
|
||||
namespace: document.getElementById('s3tablesTablePolicyNamespace').value,
|
||||
name: document.getElementById('s3tablesTablePolicyName').value
|
||||
});
|
||||
if (!confirm('Delete the policy for table ' + document.getElementById('s3tablesTablePolicyName').value + '? This cannot be undone.')) return;
|
||||
setS3TablesTablePolicyMutationInFlight(true);
|
||||
try {
|
||||
const response = await fetch(s3tBasePath(`/api/s3tables/table-policy?${query.toString()}`), { method: 'DELETE', headers: s3tWriteHeaders() });
|
||||
const data = await response.json();
|
||||
setS3TablesTablePolicyMutationInFlight(false);
|
||||
const stillCurrent = target === currentS3TablesTablePolicyTarget();
|
||||
if (!response.ok) {
|
||||
alert(data.error || 'Failed to delete policy');
|
||||
if (stillCurrent) {
|
||||
alert(data.error || 'Failed to delete policy');
|
||||
} else {
|
||||
console.error('Error deleting table policy (no longer the open resource): ' + (data.error || 'unknown error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
alert('Policy deleted');
|
||||
document.getElementById('s3tablesTablePolicyText').value = '';
|
||||
commitPolicyTextareaToEditor('s3tablesTable');
|
||||
if (!stillCurrent) return;
|
||||
s3tablesTablePolicyModal.hide();
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} catch (error) {
|
||||
alert('Failed to delete policy: ' + error.message);
|
||||
setS3TablesTablePolicyMutationInFlight(false);
|
||||
if (target === currentS3TablesTablePolicyTarget()) {
|
||||
alert('Failed to delete policy: ' + error.message);
|
||||
} else {
|
||||
console.error('Error deleting table policy: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -172,32 +172,6 @@ templ Policies(data dash.PoliciesData) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Groups a statement's Sid/Effect and its Action/Resource/Principal
|
||||
sections in the structured policy editor. Plain <fieldset>/<legend>
|
||||
render fine natively, but Bootstrap's form reset stretches <legend> to
|
||||
the fieldset's full width (float: left; width: 100%), which loses the
|
||||
usual "notch in the border" look and makes the label bar as wide as
|
||||
the card. Undo just that here so the legend hugs its content instead.
|
||||
*/
|
||||
.policy-stmt-fieldset {
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
.policy-stmt-legend {
|
||||
float: none;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
</style>
|
||||
|
||||
@PolicyDatalists()
|
||||
|
||||
<!-- Create Policy Modal -->
|
||||
@@ -352,6 +326,11 @@ templ Policies(data dash.PoliciesData) {
|
||||
<script>
|
||||
// Current policy being viewed/edited
|
||||
let currentPolicy = null;
|
||||
// Drop edit-modal GET responses superseded by a later editPolicy call,
|
||||
// so a stalled load for one policy can't populate the editor while the
|
||||
// name field already says another. Same pattern as the bucket and
|
||||
// S3 Tables policy dialogs.
|
||||
let editPolicyRequestSeq = 0;
|
||||
|
||||
// Event listeners for policy actions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -600,7 +579,9 @@ templ Policies(data dash.PoliciesData) {
|
||||
policyEditors.edit = { version: '2012-10-17', statements: [], otherFields: {} };
|
||||
renderPolicyEditor('edit');
|
||||
|
||||
// Fetch policy data
|
||||
// Fetch policy data; drop the response if another editPolicy call
|
||||
// superseded this one meanwhile.
|
||||
const requestSeq = ++editPolicyRequestSeq;
|
||||
fetch(basePath('/api/object-store/policies/' + encodeURIComponent(policyName)))
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
@@ -609,11 +590,12 @@ templ Policies(data dash.PoliciesData) {
|
||||
return response.json();
|
||||
})
|
||||
.then(policy => {
|
||||
if (requestSeq !== editPolicyRequestSeq) return;
|
||||
currentPolicy = policy;
|
||||
document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2);
|
||||
let state;
|
||||
try {
|
||||
state = policyDocToEditorState(policy.document);
|
||||
state = policyDocToEditorState('edit', policy.document);
|
||||
} catch (e) {
|
||||
// Valid JSON the structured editor can't model. The JSON tab
|
||||
// exists for exactly this, so hand the document over to it
|
||||
@@ -630,6 +612,7 @@ templ Policies(data dash.PoliciesData) {
|
||||
editorTab.show();
|
||||
})
|
||||
.catch(error => {
|
||||
if (requestSeq !== editPolicyRequestSeq) return;
|
||||
console.error('Error:', error);
|
||||
showAlert('Error loading policy for editing: ' + error.message, 'error');
|
||||
const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,10 @@
|
||||
package app
|
||||
|
||||
import "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// PolicyActionSuggestions feeds the <datalist> used by the structured policy
|
||||
// editor's Action inputs. This is an input-assistance aid, NOT a validation
|
||||
@@ -9,6 +13,18 @@ import "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
// like "s3:Get*", etc.).
|
||||
var PolicyActionSuggestions = buildPolicyActionSuggestions()
|
||||
|
||||
// S3TablesPolicyActionSuggestions is the s3tables: subset, for the S3 Tables
|
||||
// policy editors whose evaluator only matches s3tables actions.
|
||||
var S3TablesPolicyActionSuggestions = func() []string {
|
||||
var out []string
|
||||
for _, a := range PolicyActionSuggestions {
|
||||
if strings.HasPrefix(a, "s3tables:") {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
func buildPolicyActionSuggestions() []string {
|
||||
return []string{
|
||||
// s3: actions, sourced from the constants used elsewhere for policy
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
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.
|
||||
// PolicyDatalists renders the <datalist> elements the shared visual policy
|
||||
// editor (weed/admin/static/js/policy_editor.js) attaches its
|
||||
// action/resource/principal <input list="..."> suggestions to, plus the
|
||||
// styles the editor's generated markup depends on. Any page embedding that
|
||||
// editor must render this once; the 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() {
|
||||
<style>
|
||||
/* Groups a statement's Sid/Effect and its Action/Resource/Principal
|
||||
sections in the structured policy editor. Plain <fieldset>/<legend>
|
||||
render fine natively, but Bootstrap's form reset stretches <legend> to
|
||||
the fieldset's full width (float: left; width: 100%), which loses the
|
||||
usual "notch in the border" look and makes the label bar as wide as
|
||||
the card. Undo just that here so the legend hugs its content instead.
|
||||
*/
|
||||
.policy-stmt-fieldset {
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
.policy-stmt-legend {
|
||||
float: none;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Datalist of suggested action names, shared by every policy editor on this page -->
|
||||
<datalist id="policyActionSuggestions">
|
||||
for _, action := range PolicyActionSuggestions {
|
||||
@@ -16,6 +42,14 @@ templ PolicyDatalists() {
|
||||
}
|
||||
</datalist>
|
||||
|
||||
<!-- The s3tables: subset, for editors over the S3 Tables policy engine
|
||||
(registered with actionDatalistId: 's3tablesPolicyActionSuggestions') -->
|
||||
<datalist id="s3tablesPolicyActionSuggestions">
|
||||
for _, action := range S3TablesPolicyActionSuggestions {
|
||||
<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. -->
|
||||
|
||||
@@ -8,14 +8,14 @@ package app
|
||||
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.
|
||||
// PolicyDatalists renders the <datalist> elements the shared visual policy
|
||||
// editor (weed/admin/static/js/policy_editor.js) attaches its
|
||||
// action/resource/principal <input list="..."> suggestions to, plus the
|
||||
// styles the editor's generated markup depends on. Any page embedding that
|
||||
// editor must render this once; the 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
|
||||
@@ -37,7 +37,7 @@ func PolicyDatalists() templ.Component {
|
||||
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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<style>\n\t/* Groups a statement's Sid/Effect and its Action/Resource/Principal\n\t sections in the structured policy editor. Plain <fieldset>/<legend>\n\t render fine natively, but Bootstrap's form reset stretches <legend> to\n\t the fieldset's full width (float: left; width: 100%), which loses the\n\t usual \"notch in the border\" look and makes the label bar as wide as\n\t the card. Undo just that here so the legend hugs its content instead.\n\t*/\n\t.policy-stmt-fieldset {\n\t\tborder: 1px solid var(--bs-border-color, #dee2e6);\n\t\tborder-radius: 0.375rem;\n\t\tpadding: 0 0.75rem 0.75rem;\n\t\tmargin: 0.75rem 0 0;\n\t}\n\t.policy-stmt-legend {\n\t\tfloat: none;\n\t\twidth: auto;\n\t\tmax-width: 100%;\n\t\tpadding: 0 0.5rem;\n\t\tmargin: 0 0 0.25rem;\n\t\tfont-size: 0.8125rem;\n\t\tfont-weight: 600;\n\t\tcolor: var(--bs-secondary-color, #6c757d);\n\t}\n\t</style><!-- 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
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func PolicyDatalists() templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/policy_datalists.templ`, Line: 41, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -60,7 +60,30 @@ func PolicyDatalists() templ.Component {
|
||||
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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</datalist><!-- The s3tables: subset, for editors over the S3 Tables policy engine\n\t (registered with actionDatalistId: 's3tablesPolicyActionSuggestions') --><datalist id=\"s3tablesPolicyActionSuggestions\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, action := range S3TablesPolicyActionSuggestions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, 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: 49, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</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
|
||||
}
|
||||
|
||||
@@ -1410,7 +1410,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
|
||||
// Drop responses that arrive after another bucket was opened
|
||||
const requestSeq = ++policyRequestSeq;
|
||||
fetch(basePath('/api/s3/buckets/' + bucketName + '/policy'))
|
||||
fetch(basePath('/api/s3/buckets/' + encodeURIComponent(bucketName) + '/policy'))
|
||||
.then(parseLifecycleResponse)
|
||||
.then(({ ok, data }) => {
|
||||
if (requestSeq !== policyRequestSeq) return;
|
||||
@@ -1422,8 +1422,11 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
return;
|
||||
}
|
||||
document.getElementById('bucketPolicyEditorWrapper').style.display = '';
|
||||
// policy_text carries stored bytes the server-side
|
||||
// decoder rejects; the JSON tab shows them so they
|
||||
// can be fixed or deleted.
|
||||
document.getElementById('bucketPolicyDocument').value =
|
||||
data.policy ? JSON.stringify(data.policy, null, 2) : '';
|
||||
data.policy ? JSON.stringify(data.policy, null, 2) : (data.policy_text || '');
|
||||
bucketPolicyLoaded = true;
|
||||
loadPolicyTextareaIntoEditor('bucket');
|
||||
})
|
||||
@@ -1454,8 +1457,8 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
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.');
|
||||
if (!raw || !policyTextHasStatements(raw)) {
|
||||
alert('Add at least one statement, or use Delete to remove the bucket policy.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1471,13 +1474,18 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
alert(validationError);
|
||||
return;
|
||||
}
|
||||
// The admin API decodes only Version and Statement, so warn
|
||||
// before other top-level fields are silently dropped - the IAM
|
||||
// policies page already does. (The S3 Tables dialogs don't:
|
||||
// their backend stores the document verbatim.)
|
||||
if (!confirmPolicyFieldDiscard('bucket')) 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'), {
|
||||
fetch(basePath('/api/s3/buckets/' + encodeURIComponent(targetBucket) + '/policy'), {
|
||||
method: 'PUT',
|
||||
headers: csrfHeaders(),
|
||||
body: JSON.stringify({ policy: policy })
|
||||
@@ -1524,7 +1532,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
// hide or reload over whatever that other bucket is now showing.
|
||||
const targetBucket = policyEditorBucket;
|
||||
setBucketPolicyMutationInFlight(true);
|
||||
fetch(basePath('/api/s3/buckets/' + targetBucket + '/policy'), {
|
||||
fetch(basePath('/api/s3/buckets/' + encodeURIComponent(targetBucket) + '/policy'), {
|
||||
method: 'DELETE',
|
||||
headers: csrfHeaders()
|
||||
})
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -449,10 +449,13 @@ dataset = lance.dataset(table.location, storage_options=table.storage_options)`
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesBucketPolicy()">
|
||||
<button type="button" class="btn btn-outline-danger" id="s3tablesBucketPolicyDeleteBtn" onclick="deleteS3TablesBucketPolicy()">
|
||||
<i class="fas fa-trash me-1"></i>Delete Policy
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<!-- type="button": a submit button would make Enter in any
|
||||
single-line editor input implicitly submit a half-built
|
||||
policy, which the backend stores verbatim. -->
|
||||
<button type="button" class="btn btn-primary" id="s3tablesBucketPolicySaveBtn">
|
||||
<i class="fas fa-save me-1"></i>Save Policy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -690,7 +690,7 @@ dataset = lance.dataset(table.location, storage_options=table.storage_options)`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"modal fade\" id=\"s3tablesBucketPolicyModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesBucketPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-xl\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesBucketPolicyModalLabel\"><i class=\"fas fa-shield-alt me-2\"></i>Table Bucket Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesBucketPolicyForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesBucketPolicyArn\" name=\"bucket_arn\"><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.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" onclick=\"deleteS3TablesBucketPolicy()\"><i class=\"fas fa-trash me-1\"></i>Delete Policy</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i>Save Policy</button></div></form></div></div></div><div class=\"modal fade\" id=\"s3tablesTagsModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesTagsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesTagsModalLabel\"><i class=\"fas fa-tags me-2\"></i>Resource Tags</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesTagsForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesTagsResourceArn\" name=\"resource_arn\"><div class=\"mb-3\"><label class=\"form-label\">Existing Tags</label><pre class=\"bg-light p-3 border rounded\" id=\"s3tablesTagsList\">Loading...</pre></div><div class=\"mb-3\"><label for=\"s3tablesTagsInput\" class=\"form-label\">Add or Update Tags</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsInput\" placeholder=\"key1=value1,key2=value2\"></div><div class=\"mb-3\"><label for=\"s3tablesTagsDeleteInput\" class=\"form-label\">Remove Tag Keys</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsDeleteInput\" placeholder=\"key1,key2\"></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" onclick=\"deleteS3TablesTags()\"><i class=\"fas fa-trash me-1\"></i>Remove Tags</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i>Update Tags</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitS3TablesBuckets();\n\t\t});\n\t</script>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"modal fade\" id=\"s3tablesBucketPolicyModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesBucketPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-xl\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesBucketPolicyModalLabel\"><i class=\"fas fa-shield-alt me-2\"></i>Table Bucket Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesBucketPolicyForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesBucketPolicyArn\" name=\"bucket_arn\"><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.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" id=\"s3tablesBucketPolicyDeleteBtn\" onclick=\"deleteS3TablesBucketPolicy()\"><i class=\"fas fa-trash me-1\"></i>Delete Policy</button><!-- type=\"button\": a submit button would make Enter in any\n\t\t\t\t\t\t single-line editor input implicitly submit a half-built\n\t\t\t\t\t\t policy, which the backend stores verbatim. --><button type=\"button\" class=\"btn btn-primary\" id=\"s3tablesBucketPolicySaveBtn\"><i class=\"fas fa-save me-1\"></i>Save Policy</button></div></form></div></div></div><div class=\"modal fade\" id=\"s3tablesTagsModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesTagsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesTagsModalLabel\"><i class=\"fas fa-tags me-2\"></i>Resource Tags</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesTagsForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesTagsResourceArn\" name=\"resource_arn\"><div class=\"mb-3\"><label class=\"form-label\">Existing Tags</label><pre class=\"bg-light p-3 border rounded\" id=\"s3tablesTagsList\">Loading...</pre></div><div class=\"mb-3\"><label for=\"s3tablesTagsInput\" class=\"form-label\">Add or Update Tags</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsInput\" placeholder=\"key1=value1,key2=value2\"></div><div class=\"mb-3\"><label for=\"s3tablesTagsDeleteInput\" class=\"form-label\">Remove Tag Keys</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsDeleteInput\" placeholder=\"key1,key2\"></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" onclick=\"deleteS3TablesTags()\"><i class=\"fas fa-trash me-1\"></i>Remove Tags</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i>Update Tags</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitS3TablesBuckets();\n\t\t});\n\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -292,10 +292,13 @@ templ S3TablesTables(data dash.S3TablesTablesData) {
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-outline-danger" onclick="deleteS3TablesTablePolicy()">
|
||||
<button type="button" class="btn btn-outline-danger" id="s3tablesTablePolicyDeleteBtn" onclick="deleteS3TablesTablePolicy()">
|
||||
<i class="fas fa-trash me-1"></i>Delete Policy
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<!-- type="button": a submit button would make Enter in any
|
||||
single-line editor input implicitly submit a half-built
|
||||
policy, which the backend stores verbatim. -->
|
||||
<button type="button" class="btn btn-primary" id="s3tablesTablePolicySaveBtn">
|
||||
<i class="fas fa-save me-1"></i>Save Policy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -549,7 +549,7 @@ func S3TablesTables(data dash.S3TablesTablesData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"modal fade\" id=\"s3tablesTablePolicyModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesTablePolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-xl\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesTablePolicyModalLabel\"><i class=\"fas fa-shield-alt me-2\"></i>Table Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesTablePolicyForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesTablePolicyBucketArn\" name=\"bucket_arn\"> <input type=\"hidden\" id=\"s3tablesTablePolicyNamespace\" name=\"namespace\"> <input type=\"hidden\" id=\"s3tablesTablePolicyName\" name=\"name\"><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\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" onclick=\"deleteS3TablesTablePolicy()\"><i class=\"fas fa-trash me-1\"></i>Delete Policy</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i>Save Policy</button></div></form></div></div></div><div class=\"modal fade\" id=\"s3tablesTagsModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesTagsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesTagsModalLabel\"><i class=\"fas fa-tags me-2\"></i>Resource Tags</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesTagsForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesTagsResourceArn\" name=\"resource_arn\"><div class=\"mb-3\"><label class=\"form-label\">Existing Tags</label><pre class=\"bg-light p-3 border rounded\" id=\"s3tablesTagsList\">Loading...</pre></div><div class=\"mb-3\"><label for=\"s3tablesTagsInput\" class=\"form-label\">Add or Update Tags</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsInput\" placeholder=\"key1=value1,key2=value2\"></div><div class=\"mb-3\"><label for=\"s3tablesTagsDeleteInput\" class=\"form-label\">Remove Tag Keys</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsDeleteInput\" placeholder=\"key1,key2\"></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" onclick=\"deleteS3TablesTags()\"><i class=\"fas fa-trash me-1\"></i>Remove Tags</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i>Update Tags</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitS3TablesTables();\n\t\t});\n\t</script>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"modal fade\" id=\"s3tablesTablePolicyModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesTablePolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-xl\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesTablePolicyModalLabel\"><i class=\"fas fa-shield-alt me-2\"></i>Table Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesTablePolicyForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesTablePolicyBucketArn\" name=\"bucket_arn\"> <input type=\"hidden\" id=\"s3tablesTablePolicyNamespace\" name=\"namespace\"> <input type=\"hidden\" id=\"s3tablesTablePolicyName\" name=\"name\"><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\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" id=\"s3tablesTablePolicyDeleteBtn\" onclick=\"deleteS3TablesTablePolicy()\"><i class=\"fas fa-trash me-1\"></i>Delete Policy</button><!-- type=\"button\": a submit button would make Enter in any\n\t\t\t\t\t\t single-line editor input implicitly submit a half-built\n\t\t\t\t\t\t policy, which the backend stores verbatim. --><button type=\"button\" class=\"btn btn-primary\" id=\"s3tablesTablePolicySaveBtn\"><i class=\"fas fa-save me-1\"></i>Save Policy</button></div></form></div></div></div><div class=\"modal fade\" id=\"s3tablesTagsModal\" tabindex=\"-1\" aria-labelledby=\"s3tablesTagsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"s3tablesTagsModalLabel\"><i class=\"fas fa-tags me-2\"></i>Resource Tags</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"s3tablesTagsForm\"><div class=\"modal-body\"><input type=\"hidden\" id=\"s3tablesTagsResourceArn\" name=\"resource_arn\"><div class=\"mb-3\"><label class=\"form-label\">Existing Tags</label><pre class=\"bg-light p-3 border rounded\" id=\"s3tablesTagsList\">Loading...</pre></div><div class=\"mb-3\"><label for=\"s3tablesTagsInput\" class=\"form-label\">Add or Update Tags</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsInput\" placeholder=\"key1=value1,key2=value2\"></div><div class=\"mb-3\"><label for=\"s3tablesTagsDeleteInput\" class=\"form-label\">Remove Tag Keys</label> <input type=\"text\" class=\"form-control\" id=\"s3tablesTagsDeleteInput\" placeholder=\"key1,key2\"></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-outline-danger\" onclick=\"deleteS3TablesTags()\"><i class=\"fas fa-trash me-1\"></i>Remove Tags</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i>Update Tags</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitS3TablesTables();\n\t\t});\n\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -865,6 +865,44 @@ func (m *IAMManager) UpdateBucketPolicy(ctx context.Context, bucketName string,
|
||||
return m.policyEngine.AddPolicy(m.getFilerAddress(), policyName, &policyDoc)
|
||||
}
|
||||
|
||||
// EnsureBucketPolicy stores the policy for a bucket only when no mirror is
|
||||
// stored yet, backfilling policies that predate the IAM integration (the
|
||||
// metadata subscription only sees changes). A present mirror is left alone,
|
||||
// so repeat calls cost one cached read. Returns whether a write happened,
|
||||
// so the caller can reconcile a write that raced a concurrent change.
|
||||
func (m *IAMManager) EnsureBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) (bool, error) {
|
||||
if !m.initialized {
|
||||
return false, fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
if bucketName == "" {
|
||||
return false, fmt.Errorf("bucket name cannot be empty")
|
||||
}
|
||||
|
||||
if existing, err := m.policyEngine.GetPolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName); err == nil && existing != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := m.UpdateBucketPolicy(ctx, bucketName, policyJSON); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RemoveBucketPolicy deletes the stored policy for a bucket. Removing a
|
||||
// policy that was never stored is a success.
|
||||
func (m *IAMManager) RemoveBucketPolicy(ctx context.Context, bucketName string) error {
|
||||
if !m.initialized {
|
||||
return fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
if bucketName == "" {
|
||||
return fmt.Errorf("bucket name cannot be empty")
|
||||
}
|
||||
|
||||
return m.policyEngine.DeletePolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName)
|
||||
}
|
||||
|
||||
// AssumeRoleWithWebIdentity assumes a role using web identity (OIDC)
|
||||
func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) {
|
||||
if !m.initialized {
|
||||
|
||||
@@ -357,6 +357,20 @@ func (e *PolicyEngine) AddPolicy(filerAddress string, name string, policy *Polic
|
||||
return e.store.StorePolicy(context.Background(), filerAddress, name, policy)
|
||||
}
|
||||
|
||||
// GetPolicy returns a stored policy document, or an error when it does not
|
||||
// exist in the configured store.
|
||||
func (e *PolicyEngine) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
|
||||
if !e.initialized {
|
||||
return nil, fmt.Errorf("policy engine not initialized")
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
return e.store.GetPolicy(ctx, filerAddress, name)
|
||||
}
|
||||
|
||||
// DeletePolicy removes a policy from the configured store.
|
||||
func (e *PolicyEngine) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
if !e.initialized {
|
||||
|
||||
@@ -76,6 +76,11 @@ type IdentityAccessManagement struct {
|
||||
// Bucket policy engine for evaluating bucket policies
|
||||
policyEngine *BucketPolicyEngine
|
||||
|
||||
// primeBucketForIAM loads a bucket's config (and with it the advanced-IAM
|
||||
// bucket-policy mirror backfill) before an IAM authorization that will
|
||||
// evaluate that mirror. Set by NewS3ApiServer; nil in tests.
|
||||
primeBucketForIAM func(bucket string)
|
||||
|
||||
// Cached policy engine for IAM policy fallback evaluation.
|
||||
// Keyed by policy name, kept in sync by PutPolicy/DeletePolicy.
|
||||
iamPolicyEngine *policy_engine.PolicyEngine
|
||||
@@ -2865,6 +2870,13 @@ func (iam *IdentityAccessManagement) AuthorizeObjectDelete(r *http.Request, iden
|
||||
func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity *Identity, action Action, bucket string, object string) s3err.ErrorCode {
|
||||
ctx := r.Context()
|
||||
|
||||
// The evaluation below consults the bucket-policy:<bucket> mirror, so the
|
||||
// bucket's lazy load (which backfills that mirror) must happen first -
|
||||
// nothing earlier on a denied request's path would ever trigger it.
|
||||
if iam.primeBucketForIAM != nil && bucket != "" {
|
||||
iam.primeBucketForIAM(bucket)
|
||||
}
|
||||
|
||||
// Get session info from request headers
|
||||
// First check for JWT-based authentication headers (SeaweedFSSessionTokenHeader)
|
||||
sessionToken := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
|
||||
|
||||
@@ -184,6 +184,7 @@ func (s3a *S3ApiServer) onCircuitBreakerConfigChange(dir string, oldEntry *filer
|
||||
func (s3a *S3ApiServer) onBucketMetadataChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
||||
if dir == s3a.option.BucketsPath {
|
||||
s3a.maintainBucketOwnerIndex(oldEntry, newEntry)
|
||||
s3a.mirrorBucketPolicyToIAM(oldEntry, newEntry)
|
||||
if newEntry != nil {
|
||||
// Update bucket registry (existing functionality)
|
||||
s3a.bucketRegistry.LoadBucketMetadata(newEntry)
|
||||
|
||||
@@ -5,24 +5,22 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxBucketPolicySize mirrors AWS S3's 20 KB bucket-policy limit, enforced
|
||||
// by both writers (the S3 gateway's PutBucketPolicy and the admin UI) so
|
||||
// neither surface can store a document the other refuses to manage.
|
||||
const MaxBucketPolicySize = 20 * 1024
|
||||
|
||||
// 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.
|
||||
// the generic structural checks in ValidatePolicy - callers run that first,
|
||||
// so the version and non-empty-statement rules are not re-checked here. 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 {
|
||||
|
||||
@@ -54,15 +54,17 @@ func TestValidateBucketPolicy(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("bad version", func(t *testing.T) {
|
||||
// Version and non-empty-statement rules live in ValidatePolicy,
|
||||
// which callers run first.
|
||||
doc := &PolicyDocument{Version: "2008-10-17", Statement: []PolicyStatement{validStatement()}}
|
||||
if err := ValidateBucketPolicy(doc, bucket); err == nil {
|
||||
if err := ValidatePolicy(doc); 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 {
|
||||
if err := ValidatePolicy(doc); err == nil {
|
||||
t.Error("expected error for zero statements")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -381,6 +381,19 @@ func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.Err
|
||||
|
||||
config := s3a.newBucketConfigFromEntry(bucket, entry)
|
||||
|
||||
// A cold load is the first time this gateway learns the bucket's policy
|
||||
// exists, and the metadata subscription only mirrors changes - a policy
|
||||
// that predates the IAM integration would otherwise never reach the
|
||||
// advanced-IAM mirror and its grants would not bind on the IAM path.
|
||||
// Synchronous: the IAM auth path primes the bucket through here before
|
||||
// evaluating the mirror, so the backfill has to land first - a one-time
|
||||
// cost on the load that discovers the policy. Raw entry bytes, not the
|
||||
// parsed document, so the backfill can byte-compare against a later
|
||||
// entry read when it reconciles.
|
||||
if policyJSON := entry.Extended[BUCKET_POLICY_METADATA_KEY]; len(policyJSON) > 0 && config.BucketPolicy != nil && s3a.bucketPolicyIAMManager() != nil {
|
||||
s3a.ensureBucketPolicyInIAM(bucket, policyJSON)
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
s3a.bucketConfigCache.Set(bucket, config)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
@@ -75,7 +77,7 @@ func (s3a *S3ApiServer) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Re
|
||||
glog.V(3).Infof("PutBucketPolicyHandler: bucket=%s", bucket)
|
||||
|
||||
// Read policy document from request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, policy_engine.MaxBucketPolicySize+1))
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to read bucket policy request body: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPolicyDocument)
|
||||
@@ -83,6 +85,11 @@ func (s3a *S3ApiServer) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if len(body) > policy_engine.MaxBucketPolicySize {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrPolicyTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate policy document
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
if err := json.Unmarshal(body, &policyDoc); err != nil {
|
||||
@@ -299,37 +306,157 @@ func (s3a *S3ApiServer) deleteBucketPolicy(bucket string) error {
|
||||
|
||||
// updateBucketPolicyInIAM updates the IAM system with the new bucket policy
|
||||
func (s3a *S3ApiServer) updateBucketPolicyInIAM(bucket string, policyDoc *policy_engine.PolicyDocument) error {
|
||||
// Update IAM integration with new bucket policy
|
||||
if s3a.iam.iamIntegration != nil {
|
||||
// Type assert to access the concrete implementation which has access to iamManager
|
||||
if s3Integration, ok := s3a.iam.iamIntegration.(*S3IAMIntegration); ok {
|
||||
if s3Integration.iamManager != nil {
|
||||
glog.V(2).Infof("Updated bucket policy for %s in IAM system", bucket)
|
||||
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal policy: %w", err)
|
||||
}
|
||||
|
||||
return s3Integration.iamManager.UpdateBucketPolicy(context.Background(), bucket, policyJSON)
|
||||
}
|
||||
}
|
||||
iamManager := s3a.bucketPolicyIAMManager()
|
||||
if iamManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal policy: %w", err)
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Updated bucket policy for %s in IAM system", bucket)
|
||||
return iamManager.UpdateBucketPolicy(context.Background(), bucket, policyJSON)
|
||||
}
|
||||
|
||||
// ensureBucketPolicyInIAM backfills the IAM mirror for a policy the
|
||||
// subscription never saw change (one that predates the IAM integration).
|
||||
// Called from the lazy bucket-config load; a present mirror is left alone.
|
||||
func (s3a *S3ApiServer) ensureBucketPolicyInIAM(bucket string, policyJSON []byte) {
|
||||
iamManager := s3a.bucketPolicyIAMManager()
|
||||
if iamManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wrote, err := iamManager.EnsureBucketPolicy(context.Background(), bucket, policyJSON)
|
||||
if err != nil {
|
||||
glog.Warningf("backfill bucket policy for %s into IAM: %v", bucket, err)
|
||||
return
|
||||
}
|
||||
if !wrote {
|
||||
return
|
||||
}
|
||||
|
||||
// The check-then-write above can race a concurrent policy change or
|
||||
// delete: the event-driven mirror may have landed in between, and this
|
||||
// write would then have re-stored bytes that are already stale - with
|
||||
// no later event to heal it. Reconcile against a fresh entry read,
|
||||
// which is authoritative; anything changing after this read fires its
|
||||
// own event, and the mirror for it finds this write already present.
|
||||
entry, err := s3a.getBucketEntry(bucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
if err := s3a.removeBucketPolicyFromIAM(bucket); err != nil {
|
||||
glog.Warningf("remove bucket policy for %s from IAM: %v", bucket, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
current := entry.Extended[BUCKET_POLICY_METADATA_KEY]
|
||||
if bytes.Equal(current, policyJSON) {
|
||||
return
|
||||
}
|
||||
if len(current) == 0 {
|
||||
if err := s3a.removeBucketPolicyFromIAM(bucket); err != nil {
|
||||
glog.Warningf("remove bucket policy for %s from IAM: %v", bucket, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
if err := json.Unmarshal(current, &policyDoc); err != nil {
|
||||
glog.Warningf("backfill bucket policy for %s into IAM: parse: %v", bucket, err)
|
||||
return
|
||||
}
|
||||
if err := s3a.updateBucketPolicyInIAM(bucket, &policyDoc); err != nil {
|
||||
glog.Warningf("backfill bucket policy for %s into IAM: %v", bucket, err)
|
||||
}
|
||||
}
|
||||
|
||||
// removeBucketPolicyFromIAM removes the bucket policy from the IAM system
|
||||
func (s3a *S3ApiServer) removeBucketPolicyFromIAM(bucket string) error {
|
||||
// This would remove the bucket policy from our advanced IAM system
|
||||
iamManager := s3a.bucketPolicyIAMManager()
|
||||
if iamManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Removed bucket policy for %s from IAM system", bucket)
|
||||
return iamManager.RemoveBucketPolicy(context.Background(), bucket)
|
||||
}
|
||||
|
||||
// TODO: Integrate with IAM manager to remove resource-based policies
|
||||
// s3a.iam.iamIntegration.iamManager.RemoveBucketPolicy(bucket)
|
||||
|
||||
// bucketPolicyIAMManager returns the advanced-IAM manager the
|
||||
// "bucket-policy:<bucket>" mirror lives in, or nil when the integration is
|
||||
// not enabled.
|
||||
func (s3a *S3ApiServer) bucketPolicyIAMManager() *integration.IAMManager {
|
||||
if s3a.iam == nil || s3a.iam.iamIntegration == nil {
|
||||
return nil
|
||||
}
|
||||
if s3Integration, ok := s3a.iam.iamIntegration.(*S3IAMIntegration); ok {
|
||||
return s3Integration.iamManager
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirrorBucketPolicyToIAM keeps the "bucket-policy:<bucket>" IAM mirror in
|
||||
// sync with the policy stored on a bucket's filer entry. Driven from the
|
||||
// metadata subscription so it covers every writer - this gateway's own
|
||||
// PutBucketPolicy, another gateway's, the admin UI, bucket deletion, and
|
||||
// rename - where the handlers' direct calls only ever covered the first.
|
||||
func (s3a *S3ApiServer) mirrorBucketPolicyToIAM(oldEntry, newEntry *filer_pb.Entry) {
|
||||
if s3a.bucketPolicyIAMManager() == nil {
|
||||
return
|
||||
}
|
||||
removeName, updateName, updatePolicy := bucketPolicyMirrorOps(oldEntry, newEntry)
|
||||
if removeName != "" {
|
||||
if err := s3a.removeBucketPolicyFromIAM(removeName); err != nil {
|
||||
glog.Warningf("remove bucket policy for %s from IAM: %v", removeName, err)
|
||||
}
|
||||
}
|
||||
if updateName == "" {
|
||||
return
|
||||
}
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
if err := json.Unmarshal(updatePolicy, &policyDoc); err != nil {
|
||||
glog.Warningf("mirror bucket policy for %s to IAM: parse: %v", updateName, err)
|
||||
return
|
||||
}
|
||||
if err := s3a.updateBucketPolicyInIAM(updateName, &policyDoc); err != nil {
|
||||
glog.Warningf("mirror bucket policy for %s to IAM: %v", updateName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// bucketPolicyMirrorOps computes what a bucket entry change means for the
|
||||
// IAM mirror: a name whose mirror must be removed, and a (name, policy) to
|
||||
// write. A rename delivers both entries under different names in one event,
|
||||
// and the old name's mirror has to move even when the policy bytes are
|
||||
// unchanged - equality only short-circuits same-name updates.
|
||||
func bucketPolicyMirrorOps(oldEntry, newEntry *filer_pb.Entry) (removeName, updateName string, updatePolicy []byte) {
|
||||
var oldName, newName string
|
||||
var oldPolicy, newPolicy []byte
|
||||
if oldEntry != nil {
|
||||
oldName = oldEntry.Name
|
||||
oldPolicy = oldEntry.Extended[BUCKET_POLICY_METADATA_KEY]
|
||||
}
|
||||
if newEntry != nil {
|
||||
newName = newEntry.Name
|
||||
newPolicy = newEntry.Extended[BUCKET_POLICY_METADATA_KEY]
|
||||
}
|
||||
if oldName != "" && oldName != newName && len(oldPolicy) > 0 {
|
||||
removeName = oldName
|
||||
oldPolicy = nil
|
||||
}
|
||||
if newName == "" || bytes.Equal(oldPolicy, newPolicy) {
|
||||
return
|
||||
}
|
||||
if len(newPolicy) == 0 {
|
||||
removeName = newName
|
||||
return
|
||||
}
|
||||
updateName = newName
|
||||
updatePolicy = newPolicy
|
||||
return
|
||||
}
|
||||
|
||||
// GetPublicAccessBlockHandler Retrieves the PublicAccessBlock configuration for an S3 bucket
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetPublicAccessBlock.html
|
||||
func (s3a *S3ApiServer) GetPublicAccessBlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
func bucketEntryWithPolicy(name, policy string) *filer_pb.Entry {
|
||||
entry := &filer_pb.Entry{Name: name}
|
||||
if policy != "" {
|
||||
entry.Extended = map[string][]byte{BUCKET_POLICY_METADATA_KEY: []byte(policy)}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func TestBucketPolicyMirrorOps(t *testing.T) {
|
||||
policyA := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow"}]}`
|
||||
policyB := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny"}]}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
oldEntry *filer_pb.Entry
|
||||
newEntry *filer_pb.Entry
|
||||
removeName string
|
||||
updateName string
|
||||
updatePolicy string
|
||||
}{
|
||||
{
|
||||
name: "create without policy",
|
||||
newEntry: bucketEntryWithPolicy("a", ""),
|
||||
},
|
||||
{
|
||||
name: "policy set",
|
||||
oldEntry: bucketEntryWithPolicy("a", ""),
|
||||
newEntry: bucketEntryWithPolicy("a", policyA),
|
||||
updateName: "a",
|
||||
updatePolicy: policyA,
|
||||
},
|
||||
{
|
||||
name: "policy replaced",
|
||||
oldEntry: bucketEntryWithPolicy("a", policyA),
|
||||
newEntry: bucketEntryWithPolicy("a", policyB),
|
||||
updateName: "a",
|
||||
updatePolicy: policyB,
|
||||
},
|
||||
{
|
||||
name: "policy unchanged",
|
||||
oldEntry: bucketEntryWithPolicy("a", policyA),
|
||||
newEntry: bucketEntryWithPolicy("a", policyA),
|
||||
},
|
||||
{
|
||||
name: "policy removed",
|
||||
oldEntry: bucketEntryWithPolicy("a", policyA),
|
||||
newEntry: bucketEntryWithPolicy("a", ""),
|
||||
removeName: "a",
|
||||
},
|
||||
{
|
||||
name: "bucket deleted",
|
||||
oldEntry: bucketEntryWithPolicy("a", policyA),
|
||||
removeName: "a",
|
||||
},
|
||||
{
|
||||
name: "bucket deleted without policy",
|
||||
oldEntry: bucketEntryWithPolicy("a", ""),
|
||||
},
|
||||
{
|
||||
// The rename case: same policy bytes on both sides must still
|
||||
// move the mirror to the new name.
|
||||
name: "renamed with unchanged policy",
|
||||
oldEntry: bucketEntryWithPolicy("a", policyA),
|
||||
newEntry: bucketEntryWithPolicy("b", policyA),
|
||||
removeName: "a",
|
||||
updateName: "b",
|
||||
updatePolicy: policyA,
|
||||
},
|
||||
{
|
||||
name: "renamed with changed policy",
|
||||
oldEntry: bucketEntryWithPolicy("a", policyA),
|
||||
newEntry: bucketEntryWithPolicy("b", policyB),
|
||||
removeName: "a",
|
||||
updateName: "b",
|
||||
updatePolicy: policyB,
|
||||
},
|
||||
{
|
||||
name: "renamed and policy dropped",
|
||||
oldEntry: bucketEntryWithPolicy("a", policyA),
|
||||
newEntry: bucketEntryWithPolicy("b", ""),
|
||||
removeName: "a",
|
||||
},
|
||||
{
|
||||
name: "renamed without prior policy",
|
||||
oldEntry: bucketEntryWithPolicy("a", ""),
|
||||
newEntry: bucketEntryWithPolicy("b", policyA),
|
||||
updateName: "b",
|
||||
updatePolicy: policyA,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
removeName, updateName, updatePolicy := bucketPolicyMirrorOps(tc.oldEntry, tc.newEntry)
|
||||
if removeName != tc.removeName {
|
||||
t.Errorf("removeName = %q, want %q", removeName, tc.removeName)
|
||||
}
|
||||
if updateName != tc.updateName {
|
||||
t.Errorf("updateName = %q, want %q", updateName, tc.updateName)
|
||||
}
|
||||
if string(updatePolicy) != tc.updatePolicy {
|
||||
t.Errorf("updatePolicy = %q, want %q", updatePolicy, tc.updatePolicy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -358,6 +358,15 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
// policy conditions on s3:x-amz-server-side-encryption evaluate correctly.
|
||||
policyEngine.MultipartSSELookup = s3ApiServer.getMultipartSSEAlgorithm
|
||||
|
||||
// Advanced-IAM authorization evaluates the bucket-policy:<bucket> mirror
|
||||
// before any handler runs, so the auth path has to be what triggers the
|
||||
// lazy bucket load (and with it the mirror backfill): a grant carried
|
||||
// only by a not-yet-mirrored policy would otherwise deny forever, and
|
||||
// the denied request never reaches the handlers that load the bucket.
|
||||
iam.primeBucketForIAM = func(bucket string) {
|
||||
s3ApiServer.getBucketConfig(bucket)
|
||||
}
|
||||
|
||||
// Initialize advanced IAM system if config is provided or explicitly enabled
|
||||
if option.IamConfig != "" || option.EnableIam {
|
||||
configSource := "defaults"
|
||||
|
||||
@@ -95,6 +95,7 @@ const (
|
||||
ErrMalformedCredentialDate
|
||||
ErrMalformedPolicy
|
||||
ErrInvalidPolicyDocument
|
||||
ErrPolicyTooLarge
|
||||
ErrMissingSignHeadersTag
|
||||
ErrMissingSignTag
|
||||
ErrUnsignedHeaders
|
||||
@@ -389,6 +390,11 @@ var errorCodeResponse = map[ErrorCode]APIError{
|
||||
Description: "Policy has invalid resource.",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrPolicyTooLarge: {
|
||||
Code: "PolicyTooLarge",
|
||||
Description: "Policy exceeds the maximum allowed document size.",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrInvalidPolicyDocument: {
|
||||
Code: "InvalidPolicyDocument",
|
||||
Description: "The content of the policy document is invalid.",
|
||||
|
||||
Reference in New Issue
Block a user