// 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 ' + '' + '' + ''; }); 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 '
' + '' + '' + '
'; } // 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 ''; }).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); }