Files
seaweedfs/weed/admin/static/js/policy_editor.js
T
Chris LuandGitHub 69cc2869ad Fixes from the review of the admin bucket policy UI (#10907)
* admin: treat a missing S3 Tables policy as an empty load, not an error

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

* admin: reject policy documents the structured editor would misread

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

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

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

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

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

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

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

* admin: block zero-statement policy saves

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

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

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

* admin: confirm before deleting an S3 Tables policy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* admin: deduplicate the bucket policy write path

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

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

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

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

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

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

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

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

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

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

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

* s3: reconcile the bucket policy backfill against concurrent changes

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

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

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

1004 lines
52 KiB
JavaScript

// Shared visual policy editor: renders and edits an IAM/bucket policy
// document (Version + Statement list) via a structured form alongside a
// raw-JSON tab, kept in sync in both directions.
//
// Extracted from weed/admin/view/app/policies.templ (the IAM policy
// management page), which was its original and, for a while, only
// consumer. Any page embedding this editor must first render the shared
// datalists (see the PolicyDatalists templ component in
// weed/admin/view/app/policy_datalists.templ) and load this script after
// admin.js (for basePath/escapeHtml) and modal-alerts.js (for showAlert).
//
// Usage: call registerPolicyEditor(which, config) once to declare an
// editor instance (see its doc comment for the id conventions and
// config knobs), then setupPolicyEditor(which) once to wire up its DOM
// listeners. which is an arbitrary string ("create", "edit",
// "bucketPolicy", ...) that namespaces one editor instance's DOM ids and
// state from another's on the same page.
// Per-`which` editor configuration. See registerPolicyEditor.
const POLICY_EDITOR_CONFIG = {};
// registerPolicyEditor declares (or redeclares) the configuration for one
// editor instance. Call before setupPolicyEditor(which), and again any
// time a config value (e.g. `bucket`) needs to change for an
// already-set-up instance (setupPolicyEditor only needs to run once per
// `which`; its DOM listeners read POLICY_EDITOR_CONFIG live).
//
// config:
// textareaId - id of the JSON <textarea>. Default: which + 'PolicyDocument'.
// editorBodyId - id of the structured-editor container. Default: which + 'PolicyEditorBody'.
// addStatementBtnId - id of the "Add statement" button. Default: which + 'PolicyAddStatementBtn'.
// editorTabBtnId - id of the Editor tab button. Default: which + 'PolicyEditorTabBtn'.
// jsonTabBtnId - id of the JSON tab button. Default: which + 'PolicyJsonTabBtn'.
// actionDatalistId - id of the shared action-suggestions <datalist>. Default: 'policyActionSuggestions'.
// resourceDatalistId - id of the shared resource-suggestions <datalist>. Default: 'policyResourceSuggestions'.
// principalDatalistId - id of the shared principal-suggestions <datalist>. Default: 'policyPrincipalSuggestions'.
// requirePrincipal - if true, a new statement seeds Principal with '*'
// instead of leaving it empty, 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',
editorBodyId: which + 'PolicyEditorBody',
addStatementBtnId: which + 'PolicyAddStatementBtn',
editorTabBtnId: which + 'PolicyEditorTabBtn',
jsonTabBtnId: which + 'PolicyJsonTabBtn',
actionDatalistId: 'policyActionSuggestions',
resourceDatalistId: 'policyResourceSuggestions',
principalDatalistId: 'policyPrincipalSuggestions',
requirePrincipal: false,
allowNegation: true,
bucket: null,
resourceSuggestions: null
}, config || {});
}
function policyEditorConfig(which) {
return POLICY_EDITOR_CONFIG[which] || (registerPolicyEditor(which, {}), POLICY_EDITOR_CONFIG[which]);
}
// Structured-editor state, one entry per modal ("create" / "edit"). Each
// entry is { version, statements: [{ sid, effect, actions, resources, extras }] }.
// "extras" holds the JSON text of any statement fields the structured
// editor doesn't expose (Principal, NotPrincipal, NotResource, Condition,
// or any future/unknown key), so they round-trip untouched.
let policyEditors = {
create: { version: '2012-10-17', statements: [], otherFields: {} },
edit: { version: '2012-10-17', statements: [], otherFields: {} }
};
// Lazily initializes and returns policyEditors[which]. 'create'/'edit'
// are pre-populated above, but a page with its own `which` (e.g. the
// bucket policy modal) doesn't populate one until its first successful
// load - and that load is asynchronous, so the Editor/JSON tabs and
// Add-statement button can be reachable before it resolves (nothing in
// this file enforces that a page hide them meanwhile). Route reads
// through this instead of policyEditors[which] directly wherever that
// race is possible, so a click in that window gets a valid empty state
// instead of a TypeError on undefined.
function policyEditorState(which) {
if (!policyEditors[which]) {
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {} };
}
return policyEditors[which];
}
const POLICY_STATEMENT_KNOWN_KEYS = ['Sid', 'Effect', 'Action', 'Resource', 'NotResource'];
// Shown for a policy that parsed as JSON but that the structured editor
// can't represent, so its editor state carries { unparsed: true } and the
// document only ever lives in the JSON tab.
const POLICY_JSON_TAB_ONLY_MESSAGE = 'This policy can only be edited on the JSON tab.';
// Maps a policy-list-item's data-field attribute to the editor-state
// array it belongs to.
const POLICY_LIST_FIELD_TO_STATE_KEY = { action: 'actions', resource: 'resources', principal: 'principalValues' };
function policyTextareaId(which) {
return policyEditorConfig(which).textareaId;
}
function 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 [];
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'];
// Converts a policy document (as parsed from JSON) into editor state.
//
// Top-level keys the editor doesn't model (e.g. Id) are kept verbatim in
// state.otherFields and merged back on serialization, so a round-trip
// through the Editor tab doesn't rewrite text the user typed in the JSON
// tab. The admin API's PolicyDocument only carries Version and Statement,
// so such fields are still dropped by the server on save; see
// confirmPolicyFieldDiscard, which warns the user before that happens.
function policyDocToEditorState(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: {} };
Object.keys(doc).forEach(function(key) {
if (POLICY_DOCUMENT_KNOWN_KEYS.indexOf(key) === -1) {
state.otherFields[key] = doc[key];
}
});
const rawStatements = doc && doc.Statement
? (Array.isArray(doc.Statement) ? doc.Statement : [doc.Statement])
: [];
rawStatements.forEach(function(stmt, idx) {
stmt = stmt || {};
if (stmt.Effect !== 'Allow' && stmt.Effect !== 'Deny') {
// Defaulting a missing/malformed Effect to "Allow" would
// silently turn e.g. a typo'd "deny" into a permissive
// statement. Reject instead of guessing.
throw new Error('Statement ' + (idx + 1) + ': Effect must be exactly "Allow" or "Deny" (got ' +
JSON.stringify(stmt.Effect === undefined ? null : stmt.Effect) + ')');
}
const hasResource = Object.prototype.hasOwnProperty.call(stmt, 'Resource');
const hasNotResource = Object.prototype.hasOwnProperty.call(stmt, 'NotResource');
if (hasResource && hasNotResource) {
// The two are mutually exclusive; a document with both isn't
// representable by the mode dropdown, so ask the user to fix
// it in the JSON tab rather than silently picking one.
throw new Error('Statement ' + (idx + 1) + ': cannot specify both Resource and NotResource');
}
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');
const hasNotPrincipal = Object.prototype.hasOwnProperty.call(stmt, 'NotPrincipal');
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;
let hasComplexPrincipal = false;
if (hasPrincipal || hasNotPrincipal) {
const principalKey = hasNotPrincipal ? 'NotPrincipal' : 'Principal';
const parsedValues = parseSimpleAwsPrincipal(stmt[principalKey]);
if (parsedValues) {
principalMode = principalKey;
principalValues = parsedValues;
principalManaged = true;
} else {
// Bare string/array, a type other than AWS, or multiple
// types at once - this v1 editor only models a single
// {"AWS": ...} form. Leave it in extras rather than
// guessing or discarding it.
hasComplexPrincipal = true;
}
}
const extras = {};
Object.keys(stmt).forEach(function(key) {
if (POLICY_STATEMENT_KNOWN_KEYS.indexOf(key) !== -1) return;
if (principalManaged && (key === 'Principal' || key === 'NotPrincipal')) return;
extras[key] = stmt[key];
});
state.statements.push({
sid: String(stmt.Sid || ''),
effect: stmt.Effect,
actions: normalizeToStringArray(stmt.Action),
resourceMode: resourceMode,
resources: normalizeToStringArray(hasNotResource ? stmt.NotResource : stmt.Resource),
principalMode: principalMode,
principalValues: principalValues,
hasComplexPrincipal: hasComplexPrincipal,
extras: Object.keys(extras).length ? JSON.stringify(extras, null, 2) : ''
});
});
return state;
}
// Returns the flattened list of values if `value` is a policy Principal /
// NotPrincipal expressed as one of the two forms this editor's simple
// text field models: the bare wildcard "*" (standard AWS shorthand for
// "everyone"), or a single-key {"AWS": "..."} / {"AWS": ["...", ...]}
// object. Returns null for anything else (any other bare string/array, a
// different type key, or multiple type keys at once), which the caller
// then leaves untouched in "extras".
function parseSimpleAwsPrincipal(value) {
if (value === '*') return ['*'];
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const keys = Object.keys(value);
if (keys.length !== 1 || keys[0] !== 'AWS') return null;
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
// (Sid/Effect/Action/Resource-or-NotResource) always take precedence over
// whatever is in "extras" in case of a conflicting key.
//
// Throws if a statement's "advanced fields" box holds malformed or
// non-object JSON, instead of silently dropping it: those fields can
// carry Principal/NotPrincipal/Condition, so silently continuing with an
// empty object would change the policy's authorization behavior without
// the user noticing.
function policyEditorStateToDoc(state) {
// Unmanaged top-level keys first, so Version/Statement below always win.
const doc = Object.assign({}, state.otherFields || {});
doc.Version = state.version || '2012-10-17';
doc.Statement = [];
(state.statements || []).forEach(function(s, idx) {
let stmt = {};
if (s.extras && s.extras.trim()) {
let parsed;
try {
parsed = JSON.parse(s.extras);
} catch (e) {
throw new Error('Statement ' + (idx + 1) + ': advanced fields contain invalid JSON (' + e.message + ')');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Statement ' + (idx + 1) + ': advanced fields must be a JSON object');
}
stmt = parsed;
}
if (s.sid) {
stmt.Sid = s.sid;
} else {
delete stmt.Sid;
}
stmt.Effect = s.effect === 'Deny' ? 'Deny' : 'Allow';
const actions = (s.actions || []).map(function(a) { return (a || '').trim(); }).filter(Boolean);
if (actions.length) {
stmt.Action = actions.length === 1 ? actions[0] : actions;
} else {
delete stmt.Action;
}
const resources = (s.resources || []).map(function(r) { return (r || '').trim(); }).filter(Boolean);
if (resources.length) {
// Resource and NotResource are mutually exclusive; only the
// key matching the selected mode is ever written.
if (s.resourceMode === 'NotResource') {
stmt.NotResource = resources.length === 1 ? resources[0] : resources;
delete stmt.Resource;
} else {
stmt.Resource = resources.length === 1 ? resources[0] : resources;
delete stmt.NotResource;
}
} else {
delete stmt.Resource;
delete stmt.NotResource;
}
const principalValues = (s.principalValues || []).map(function(p) { return (p || '').trim(); }).filter(Boolean);
if (principalValues.length) {
// Same exclusivity rule as Resource/NotResource. Structured
// values always win over whatever "extras" held for these
// keys. A lone "*" is written as the bare wildcard (standard
// AWS shorthand for "everyone"); anything else is wrapped in
// the standard {"AWS": ...} form (v1 only models AWS
// principals).
delete stmt.Principal;
delete stmt.NotPrincipal;
const wrappedPrincipal = (principalValues.length === 1 && principalValues[0] === '*')
? '*'
: { AWS: principalValues.length === 1 ? principalValues[0] : principalValues };
if (s.principalMode === 'NotPrincipal') {
stmt.NotPrincipal = wrappedPrincipal;
} else {
stmt.Principal = wrappedPrincipal;
}
}
// If principalValues is empty, leave stmt.Principal/NotPrincipal
// untouched: it may hold a complex form preserved verbatim from
// "extras" (see parseSimpleAwsPrincipal) that the user never
// touched via this field, and clearing it here would silently
// discard it.
doc.Statement.push(stmt);
});
return doc;
}
// Renders the structured editor for the given modal ("create"/"edit")
// from policyEditors[which] into its container.
function renderPolicyEditor(which) {
const container = document.getElementById(policyEditorBodyId(which));
if (!container) return;
const state = policyEditorState(which);
if (state.unparsed) {
container.innerHTML = '<p class="text-muted">This policy uses a form the structured editor cannot show. Edit it on the JSON tab.</p>';
return;
}
if (state.statements.length === 0) {
container.innerHTML = '<p class="text-muted">No statements yet. Click "Add statement" to create one.</p>';
return;
}
let html = '';
state.statements.forEach(function(stmt, idx) {
const actionRows = stmt.actions.map(function(action, actionIdx) {
return policyListRowHtml(which, idx, 'action', actionIdx, action);
}).join('');
const resourceRows = stmt.resources.map(function(resource, resourceIdx) {
return policyListRowHtml(which, idx, 'resource', resourceIdx, resource);
}).join('');
const principalRows = stmt.principalValues.map(function(principal, principalIdx) {
return policyListRowHtml(which, idx, 'principal', principalIdx, principal);
}).join('');
html +=
'<div class="card mb-3" data-statement-index="' + idx + '">' +
'<div class="card-body">' +
'<div class="d-flex justify-content-between align-items-start mb-2">' +
'<h6 class="card-title mb-0">Statement ' + (idx + 1) + '</h6>' +
'<button type="button" class="btn btn-sm btn-outline-danger policy-remove-statement-btn" data-which="' + which + '" data-index="' + idx + '"><i class="fas fa-trash"></i></button>' +
'</div>' +
'<div class="row mb-2">' +
'<div class="col-md-6">' +
'<label class="form-label">Sid (optional)</label>' +
'<input type="text" class="form-control form-control-sm policy-stmt-sid" data-which="' + which + '" data-index="' + idx + '" value="' + escapeHtml(stmt.sid) + '">' +
'</div>' +
'<div class="col-md-6">' +
'<label class="form-label d-block">Effect</label>' +
'<div class="btn-group" role="group">' +
'<input type="radio" class="btn-check policy-stmt-effect" name="policyEffect-' + which + '-' + idx + '" id="policyEffectAllow-' + which + '-' + idx + '" data-which="' + which + '" data-index="' + idx + '" value="Allow"' + (stmt.effect === 'Allow' ? ' checked' : '') + '>' +
'<label class="btn btn-outline-success btn-sm" for="policyEffectAllow-' + which + '-' + idx + '">Allow</label>' +
'<input type="radio" class="btn-check policy-stmt-effect" name="policyEffect-' + which + '-' + idx + '" id="policyEffectDeny-' + which + '-' + idx + '" data-which="' + which + '" data-index="' + idx + '" value="Deny"' + (stmt.effect === 'Deny' ? ' checked' : '') + '>' +
'<label class="btn btn-outline-danger btn-sm" for="policyEffectDeny-' + which + '-' + idx + '">Deny</label>' +
'</div>' +
'</div>' +
'</div>' +
'<fieldset class="policy-stmt-fieldset">' +
'<legend class="policy-stmt-legend border rounded">Actions</legend>' +
'<div class="policy-action-rows" data-which="' + which + '" data-index="' + idx + '">' + actionRows + '</div>' +
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="action"><i class="fas fa-plus me-1"></i>Add action</button>' +
'</fieldset>' +
'<fieldset class="policy-stmt-fieldset">' +
'<legend class="policy-stmt-legend' + (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' + (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">' + (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 &mdash; see Advanced fields below.</div>' : '') +
'<div class="policy-principal-rows" data-which="' + which + '" data-index="' + idx + '">' + principalRows + '</div>' +
'<button type="button" class="btn btn-sm btn-outline-secondary policy-add-list-item-btn" data-which="' + which + '" data-index="' + idx + '" data-field="principal"><i class="fas fa-plus me-1"></i>Add principal</button>' +
'</fieldset>' +
'<details class="mt-3"' + (stmt.extras ? ' open' : '') + '>' +
'<summary class="text-muted">Advanced fields (Principal, NotPrincipal, Condition, raw JSON)</summary>' +
'<textarea class="form-control form-control-sm mt-2 policy-stmt-extras" data-which="' + which + '" data-index="' + idx + '" rows="4" placeholder="{}">' + escapeHtml(stmt.extras) + '</textarea>' +
'</details>' +
'</div>' +
'</div>';
});
container.innerHTML = html;
}
function policyListRowHtml(which, stmtIdx, field, itemIdx, value) {
const cfg = policyEditorConfig(which);
let listAttr = '';
if (field === 'action') listAttr = ' list="' + cfg.actionDatalistId + '"';
else if (field === 'resource') listAttr = ' list="' + cfg.resourceDatalistId + '"';
else if (field === 'principal') listAttr = ' list="' + cfg.principalDatalistId + '"';
return '<div class="input-group input-group-sm mb-1">' +
'<input type="text" class="form-control policy-list-item" ' + listAttr + ' data-which="' + which + '" data-index="' + stmtIdx + '" data-field="' + field + '" data-item-index="' + itemIdx + '" value="' + escapeHtml(value) + '">' +
'<button type="button" class="btn btn-outline-danger policy-remove-list-item-btn" data-which="' + which + '" data-index="' + stmtIdx + '" data-field="' + field + '" data-item-index="' + itemIdx + '"><i class="fas fa-times"></i></button>' +
'</div>';
}
// Reads whatever is currently displayed in the editor tab's DOM back into
// policyEditors[which], so nothing typed is lost before a save/tab-switch/serialize.
function commitPolicyEditorForm(which) {
const state = policyEditors[which];
if (!state) return;
document.querySelectorAll('.policy-stmt-sid[data-which="' + which + '"]').forEach(function(el) {
const idx = parseInt(el.getAttribute('data-index'), 10);
if (state.statements[idx]) state.statements[idx].sid = el.value;
});
document.querySelectorAll('.policy-stmt-effect[data-which="' + which + '"]:checked').forEach(function(el) {
const idx = parseInt(el.getAttribute('data-index'), 10);
if (state.statements[idx]) state.statements[idx].effect = el.value;
});
document.querySelectorAll('.policy-stmt-extras[data-which="' + which + '"]').forEach(function(el) {
const idx = parseInt(el.getAttribute('data-index'), 10);
if (state.statements[idx]) state.statements[idx].extras = el.value;
});
document.querySelectorAll('.policy-stmt-resource-mode[data-which="' + which + '"]').forEach(function(el) {
const idx = parseInt(el.getAttribute('data-index'), 10);
if (state.statements[idx]) state.statements[idx].resourceMode = el.value;
});
document.querySelectorAll('.policy-stmt-principal-mode[data-which="' + which + '"]').forEach(function(el) {
const idx = parseInt(el.getAttribute('data-index'), 10);
if (state.statements[idx]) state.statements[idx].principalMode = el.value;
});
document.querySelectorAll('.policy-list-item[data-which="' + which + '"]').forEach(function(el) {
const idx = parseInt(el.getAttribute('data-index'), 10);
const itemIdx = parseInt(el.getAttribute('data-item-index'), 10);
const field = POLICY_LIST_FIELD_TO_STATE_KEY[el.getAttribute('data-field')] || 'resources';
if (state.statements[idx] && state.statements[idx][field]) {
state.statements[idx][field][itemIdx] = el.value;
}
});
}
// Serializes policyEditors[which] into the JSON textarea. Call before
// switching to the JSON tab or before submitting, so the textarea always
// reflects the editor's current contents.
function commitPolicyEditorToTextarea(which) {
commitPolicyEditorForm(which);
const doc = policyEditorStateToDoc(policyEditors[which]);
document.getElementById(policyTextareaId(which)).value = JSON.stringify(doc, null, 2);
}
// Parses the JSON textarea into policyEditors[which] and re-renders the
// editor. Returns false (and shows an alert) if the JSON is invalid or a
// statement's Effect isn't exactly "Allow"/"Deny", leaving the JSON tab
// as the active one so the user can fix it.
function commitPolicyTextareaToEditor(which) {
const text = document.getElementById(policyTextareaId(which)).value;
if (!text || !text.trim()) {
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {} };
renderPolicyEditor(which);
return true;
}
let doc;
try {
doc = JSON.parse(text);
} catch (e) {
showAlert('Invalid JSON in policy document: ' + e.message, 'error');
return false;
}
let newState;
try {
newState = policyDocToEditorState(which, doc);
} catch (e) {
showAlert(e.message, 'error');
return false;
}
policyEditors[which] = newState;
renderPolicyEditor(which);
return true;
}
function activatePolicyTab(idKey, which) {
const btn = document.getElementById(policyEditorConfig(which)[idKey]);
if (btn) bootstrap.Tab.getOrCreateInstance(btn).show();
}
// Populates the editor for `which` from whatever is currently in its
// JSON textarea (typically right after a GET fills the textarea) and
// switches to whichever tab can actually show the result.
//
// Unlike commitPolicyTextareaToEditor - which assumes a tab is already
// showing and leaves it in place on failure so a Save can't silently
// clobber it - this function has no "current tab" to defer to: it is
// the thing that establishes one. So on a document the structured
// editor can't represent (invalid JSON, or valid JSON
// policyDocToEditorState rejects), it marks the state `unparsed` and
// switches to the JSON tab instead of leaving the Editor tab showing
// empty/stale state that a careless Save would serialize over the
// real document. Mirrors editPolicy's fallback in policies.templ.
function loadPolicyTextareaIntoEditor(which) {
const text = document.getElementById(policyTextareaId(which)).value;
if (!text || !text.trim()) {
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {} };
renderPolicyEditor(which);
activatePolicyTab('editorTabBtnId', which);
return true;
}
let doc;
try {
doc = JSON.parse(text);
} catch (e) {
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {}, unparsed: true };
renderPolicyEditor(which);
showAlert('Invalid JSON in stored policy: ' + e.message + '. ' + POLICY_JSON_TAB_ONLY_MESSAGE, 'error');
activatePolicyTab('jsonTabBtnId', which);
return false;
}
let state;
try {
state = policyDocToEditorState(which, doc);
} catch (e) {
policyEditors[which] = { version: '2012-10-17', statements: [], otherFields: {}, unparsed: true };
renderPolicyEditor(which);
showAlert(e.message + '. ' + POLICY_JSON_TAB_ONLY_MESSAGE, 'error');
activatePolicyTab('jsonTabBtnId', which);
return false;
}
policyEditors[which] = state;
renderPolicyEditor(which);
activatePolicyTab('editorTabBtnId', which);
return true;
}
function addPolicyStatement(which) {
if (policyEditorState(which).unparsed) {
showAlert(POLICY_JSON_TAB_ONLY_MESSAGE, 'error');
return;
}
const cfg = policyEditorConfig(which);
commitPolicyEditorForm(which);
// 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: seededResources,
principalMode: 'Principal', principalValues: cfg.requirePrincipal ? ['*'] : [], hasComplexPrincipal: false,
extras: ''
});
renderPolicyEditor(which);
}
// True while the JSON tab (rather than the Editor tab) is the one
// currently shown for `which`.
function isPolicyJsonTabActive(which) {
const jsonTabBtn = document.getElementById(policyEditorConfig(which).jsonTabBtnId);
return !!(jsonTabBtn && jsonTabBtn.classList.contains('active'));
}
// Commits whichever tab is currently visible into the other side, so a
// save/validate action always uses what the user is actually looking at
// instead of silently overwriting it with stale state from the tab
// they're not on. Returns false (after alerting the user) if that isn't
// possible - e.g. invalid JSON on either side - so the caller can abort.
function commitPolicyActiveTab(which) {
if (isPolicyJsonTabActive(which)) {
// The JSON tab is the source of truth right now; parse it back
// into the structured editor to keep both in sync, but leave the
// textarea's own text untouched.
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;
}
}
// 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 {
doc = JSON.parse(document.getElementById(policyTextareaId(which)).value);
} catch (e) {
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
// any other top-level key (e.g. Id) is discarded server-side on save even
// though the editor round-trips it between tabs. Warn before that happens
// rather than letting the field vanish silently. Returns false if the
// user cancels.
function confirmPolicyFieldDiscard(which) {
const otherFields = Object.keys((policyEditors[which] || {}).otherFields || {});
if (otherFields.length === 0) return true;
return confirm(
'The following top-level field(s) are not supported and will be dropped when this policy is saved: ' +
otherFields.join(', ') + '.\n\nSave anyway?');
}
// Client-side check for the requirePrincipal config knob: returns an
// error message naming the first statement missing a Principal /
// NotPrincipal, or null if the document is fine. Purely a fast-feedback
// convenience - the server (policy_engine.ValidateBucketPolicy) is the
// actual authority on this rule and re-checks it regardless.
function validatePolicyEditorDoc(which, doc) {
if (!policyEditorConfig(which).requirePrincipal) return null;
const statements = (doc && doc.Statement) || [];
for (let i = 0; i < statements.length; i++) {
const stmt = statements[i] || {};
// 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;
}
function setupPolicyEditor(which) {
const cfg = policyEditorConfig(which);
document.getElementById(cfg.addStatementBtnId).addEventListener('click', function() {
addPolicyStatement(which);
});
const editorTabBtn = document.getElementById(cfg.editorTabBtnId);
const jsonTabBtn = document.getElementById(cfg.jsonTabBtnId);
jsonTabBtn.addEventListener('show.bs.tab', function(event) {
if (policyEditorState(which).unparsed) {
// The editor never held this document; serializing its empty
// placeholder state would overwrite the textarea we are about
// to show, which is the only copy of it.
return;
}
try {
commitPolicyEditorToTextarea(which);
} catch (e) {
showAlert(e.message, 'error');
event.preventDefault();
}
});
editorTabBtn.addEventListener('show.bs.tab', function(event) {
if (!commitPolicyTextareaToEditor(which)) {
event.preventDefault();
}
});
const body = document.getElementById(policyEditorBodyId(which));
body.addEventListener('change', function(event) {
if (event.target.classList.contains('policy-stmt-resource-mode')) {
// Redraw so the NotResource hint follows the selected mode.
commitPolicyEditorForm(which);
renderPolicyEditor(which);
}
});
body.addEventListener('click', function(event) {
const removeStmtBtn = event.target.closest('.policy-remove-statement-btn');
if (removeStmtBtn) {
commitPolicyEditorForm(which);
const idx = parseInt(removeStmtBtn.getAttribute('data-index'), 10);
policyEditors[which].statements.splice(idx, 1);
renderPolicyEditor(which);
return;
}
const addItemBtn = event.target.closest('.policy-add-list-item-btn');
if (addItemBtn) {
commitPolicyEditorForm(which);
const idx = parseInt(addItemBtn.getAttribute('data-index'), 10);
const field = POLICY_LIST_FIELD_TO_STATE_KEY[addItemBtn.getAttribute('data-field')] || 'resources';
policyEditors[which].statements[idx][field].push('');
renderPolicyEditor(which);
return;
}
const removeItemBtn = event.target.closest('.policy-remove-list-item-btn');
if (removeItemBtn) {
commitPolicyEditorForm(which);
const idx = parseInt(removeItemBtn.getAttribute('data-index'), 10);
const itemIdx = parseInt(removeItemBtn.getAttribute('data-item-index'), 10);
const field = POLICY_LIST_FIELD_TO_STATE_KEY[removeItemBtn.getAttribute('data-field')] || 'resources';
policyEditors[which].statements[idx][field].splice(itemIdx, 1);
renderPolicyEditor(which);
}
});
// Populate the shared Resource datalist as the user types/focuses a
// Resource field. Bootstrap's datalist filtering then narrows down
// whatever set of options was last loaded for the current path stage.
body.addEventListener('input', function(event) {
const target = event.target;
if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'resource') {
updatePolicyResourceSuggestions(which, target);
}
});
body.addEventListener('focusin', function(event) {
const target = event.target;
if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'resource') {
updatePolicyResourceSuggestions(which, target);
}
});
// Same idea for the shared Principal datalist: a flat, one-time
// fetch (see loadPolicyPrincipalSuggestions), no per-segment logic
// needed since users/roles aren't hierarchical like bucket paths.
body.addEventListener('input', function(event) {
const target = event.target;
if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'principal') {
updatePolicyPrincipalSuggestions(which);
}
});
body.addEventListener('focusin', function(event) {
const target = event.target;
if (target.classList.contains('policy-list-item') && target.getAttribute('data-field') === 'principal') {
updatePolicyPrincipalSuggestions(which);
}
});
}
// ------------------------------------------------------------------
// Progressive Resource ARN autocomplete: suggests bucket names first
// (arn:aws:s3:::bucket), then once a bucket + "/" is typed, suggests
// arn:aws:s3:::bucket/* plus the direct subfolders one path segment at a
// time (fetched from the server on demand, one directory level per
// request, and cached per directory for the life of the page).
// ------------------------------------------------------------------
const POLICY_RESOURCE_ARN_PREFIX = 'arn:aws:s3:::';
let policyBucketArnsPromise = null;
const policyFolderListCache = new Map();
function loadPolicyBucketArns() {
if (!policyBucketArnsPromise) {
policyBucketArnsPromise = fetch(basePath('/api/s3/buckets'))
.then(function(r) { return r.ok ? r.json() : { buckets: [] }; })
.then(function(data) {
// Offer both the bucket itself and "every object in it",
// since the latter is what most Resource entries actually need.
return (data.buckets || []).reduce(function(acc, b) {
const arn = POLICY_RESOURCE_ARN_PREFIX + b.name;
acc.push(arn, arn + '/*');
return acc;
}, []);
})
.catch(function() { return []; });
}
return policyBucketArnsPromise;
}
function loadPolicyFolderNames(dirPath, prefix) {
// Send the segment still being typed as a prefix so the filer does the
// filtering: without it the server pages through every entry in the
// directory, which on a bucket of flat object keys is the whole bucket.
const key = dirPath + '\n' + prefix;
if (!policyFolderListCache.has(key)) {
policyFolderListCache.set(key, fetch(basePath('/api/files/list-folders?path=' + encodeURIComponent(dirPath) +
'&prefix=' + encodeURIComponent(prefix)))
.then(function(r) {
if (!r.ok) throw new Error('list-folders request failed with status ' + r.status);
return r.json();
})
.then(function(data) { return data.folders || []; })
.catch(function() {
// Don't let a transient failure permanently poison the
// cache for this directory; let the next call retry.
policyFolderListCache.delete(key);
return [];
}));
}
return policyFolderListCache.get(key);
}
// Figures out what stage of the ARN the user is currently typing:
// still the bucket name ("bucket"), or a folder path segment after the
// bucket ("folder", with dirPath being the filer directory to list and
// arnPrefix being the ARN text to append suggestions onto).
function policyResourcePathState(value) {
value = value || '';
if (value.indexOf(POLICY_RESOURCE_ARN_PREFIX) !== 0) {
return { stage: 'bucket' };
}
const rest = value.slice(POLICY_RESOURCE_ARN_PREFIX.length);
const segments = rest.split('/');
if (segments.length === 1) {
return { stage: 'bucket' };
}
const bucket = segments[0];
const pathSegments = segments.slice(1, segments.length - 1);
const suffix = pathSegments.length ? '/' + pathSegments.join('/') : '';
return {
stage: 'folder',
dirPath: '/buckets/' + bucket + suffix,
// The trailing, still-incomplete segment. The datalist narrows on
// it too, but sending it keeps the server's listing bounded.
prefix: segments[segments.length - 1],
arnPrefix: POLICY_RESOURCE_ARN_PREFIX + bucket + suffix
};
}
function renderPolicyDatalistOptions(datalist, values) {
datalist.innerHTML = values.map(function(v) {
return '<option value="' + escapeHtml(v) + '"></option>';
}).join('');
}
function updatePolicyResourceSuggestions(which, inputEl) {
const cfg = policyEditorConfig(which);
const datalist = document.getElementById(cfg.resourceDatalistId);
if (!datalist) return;
if (cfg.resourceSuggestions) {
renderPolicyDatalistOptions(datalist, cfg.resourceSuggestions);
return;
}
const state = policyResourcePathState(inputEl.value);
if (state.stage === 'bucket') {
if (cfg.bucket) {
// Pinned to one bucket: no need to fetch and offer every
// bucket in the cluster, and the user can't be offered an
// ARN the server would reject anyway (see
// policy_engine.ValidateBucketPolicy).
renderPolicyDatalistOptions(datalist, [
POLICY_RESOURCE_ARN_PREFIX + cfg.bucket,
POLICY_RESOURCE_ARN_PREFIX + cfg.bucket + '/*'
]);
return;
}
loadPolicyBucketArns().then(function(arns) {
renderPolicyDatalistOptions(datalist, arns);
});
return;
}
loadPolicyFolderNames(state.dirPath, state.prefix).then(function(folders) {
const options = [state.arnPrefix + '/*'];
folders.forEach(function(name) {
options.push(state.arnPrefix + '/' + name);
});
renderPolicyDatalistOptions(datalist, options);
});
}
// ------------------------------------------------------------------
// Principal autocomplete: a flat list of existing users and IAM roles,
// fetched once from /api/principals and cached for the life of the page
// (unlike Resource ARNs, users/roles have no hierarchy to drill into).
// ------------------------------------------------------------------
let policyPrincipalSuggestionsPromise = null;
function loadPolicyPrincipalSuggestions() {
if (!policyPrincipalSuggestionsPromise) {
policyPrincipalSuggestionsPromise = fetch(basePath('/api/principals'))
.then(function(r) { return r.ok ? r.json() : { principals: [] }; })
.then(function(data) { return ['*'].concat(data.principals || []); })
.catch(function() { return ['*']; });
}
return policyPrincipalSuggestionsPromise;
}
function updatePolicyPrincipalSuggestions(which) {
const datalist = document.getElementById(policyEditorConfig(which).principalDatalistId);
if (!datalist) return;
loadPolicyPrincipalSuggestions().then(function(principals) {
renderPolicyDatalistOptions(datalist, principals);
});
}
// Fills the structured editor (and the JSON tab) with a sample policy,
// regardless of which tab is currently active.
const POLICY_SAMPLE_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::my-bucket/*"
]
}
]
};
function insertSamplePolicy(which, sampleDoc) {
const doc = sampleDoc || POLICY_SAMPLE_DOCUMENT;
policyEditors[which] = policyDocToEditorState(which, doc);
renderPolicyEditor(which);
document.getElementById(policyTextareaId(which)).value = JSON.stringify(doc, null, 2);
}