Files
seaweedfs/weed/admin/static/js/policy_editor.js
T
2026-09-16 20:29:14 -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="bi bi-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="bi bi-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="bi bi-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="bi bi-exclamation-triangle 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="bi bi-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="bi bi-x"></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);
}