// Copyright 2026 Versity Software // This file is licensed under the Apache License, Version 2.0 // (the "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. /** * VersityGW IAM - shared UI helpers for the IAM pages * * Loaded by iam.html, iam-users.html, iam-roles.html and iam-oidc.html after * js/app.js. Holds the parts the three IAM pages would otherwise duplicate: * the policy editor (identity and trust variants), repeatable form rows, the * per-section access-denied state, and the shared quota numbers. */ // ============================================ // Server-side quotas (surfaced as helper text and disabled states) // ============================================ const IAM_LIMITS = { accessKeysPerUser: 2, userPolicyBytes: 2048, rolePolicyBytes: 10240, trustPolicyBytes: 2048, policyDocumentBytes: 131072, roleDescriptionChars: 1000, namePattern: /^[A-Za-z0-9+=,.@_-]+$/, nameChars: 64, pathChars: 512, tagsPerResource: 50, tagKeyChars: 128, tagValueChars: 256, tagKeyPattern: /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u, tagValuePattern: /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u, minSessionDuration: 3600, maxSessionDuration: 43200, oidcClientIds: 100, oidcClientIdChars: 255, oidcThumbprints: 5, oidcThumbprintChars: 40, oidcUrlChars: 255, listPageSize: 100 }; const IAM_ACCOUNT_ID = '000000000000'; // ============================================ // Formatting & small utilities // ============================================ function iamFormatDate(value) { if (!value) return '-'; const date = new Date(value); if (isNaN(date.getTime())) return value; return date.toLocaleString(); } function iamByteLength(text) { return new TextEncoder().encode(text || '').length; } /** * Copy machine-issued strings (ARNs, key IDs, thumbprints) to the clipboard */ async function iamCopy(text, label = 'Value') { try { await navigator.clipboard.writeText(text); showToast(label + ' copied to clipboard', 'success'); } catch (e) { showToast('Unable to copy to clipboard', 'error'); } } /** * ARNs are long. Render them monospace, truncated, with a copy button. */ function iamArnCell(arn) { if (!arn) return '-'; const safe = escapeHtml(arn); return `
${safe}
`; } function iamStatusBadge(status) { const active = status === 'Active'; const cls = active ? 'bg-green-50 text-green-700' : 'bg-yellow-50 text-yellow-700'; return `${escapeHtml(status || '-')}`; } /** * Per-section denial state. Partial access is the common case for non-root * callers, so a denied List* call reports itself in place instead of failing * the whole page. */ function iamShowAccessDenied(tableBodyId, columns, message) { const tbody = document.getElementById(tableBodyId); if (!tbody) return; tbody.innerHTML = `

${escapeHtml(message)}

`; } /** * Turn an IAM error into product copy. Deletion preconditions are the one case * where the server's own message is less useful than a prescriptive one. */ function iamErrorText(error, context) { const raw = (error && error.message) || 'Unknown error'; if (raw.startsWith('DeleteConflictPolicies') || raw.startsWith('DeleteConflict')) { return raw.includes('Role') || context === 'role' ? 'This role still has inline policies. Remove them before deleting it.' : 'This user still has access keys or inline policies. Remove them before deleting it.'; } if (raw.startsWith('AccessKeysLimitExceeded')) { return `This user already has ${IAM_LIMITS.accessKeysPerUser} access keys. Delete one before creating another.`; } if (raw.startsWith('InlinePolicyQuotaExceeded')) { return 'Saving this policy would exceed the aggregate inline-policy size for this identity.'; } return context ? `Error ${context}: ${raw}` : raw; } function iamIsAccessDenied(error) { const raw = (error && error.message) || ''; return raw.startsWith('AccessDenied') || raw.startsWith('AuthorizationError'); } /** * Terse form of an error, for places with no room for a paragraph * (stat-card notes, table cells) */ function iamShortError(error) { const raw = (error && error.message) || 'Unknown error'; if (raw.startsWith('Network error:')) return 'IAM service unreachable'; return raw.length > 90 ? raw.slice(0, 90) + '\u2026' : raw; } // ============================================ // Name / path validation (client side, matching server rules) // ============================================ function iamValidateName(name, label = 'Name') { if (!name) return `${label} is required.`; if (name.length > IAM_LIMITS.nameChars) return `${label} must be ${IAM_LIMITS.nameChars} characters or fewer.`; if (!IAM_LIMITS.namePattern.test(name)) return `${label} may contain letters, numbers and + = , . @ _ - only.`; return null; } function iamValidatePath(path) { if (!path) return null; if (path.length > IAM_LIMITS.pathChars) return `Path must be ${IAM_LIMITS.pathChars} characters or fewer.`; if (!path.startsWith('/') || !path.endsWith('/')) return 'Path must start and end with /.'; return null; } /** * Validate a collected tag list against the same rules the server applies, * so an obvious mistake is caught before a round trip. caseSensitiveKeys * selects the resource's key-comparison rule: users and roles fold key * case, OIDC providers compare keys exactly. Returns an error string, or * null when the list is acceptable. */ function iamValidateTags(tags, caseSensitiveKeys = false) { if (tags.length > IAM_LIMITS.tagsPerResource) { return `A single resource can carry ${IAM_LIMITS.tagsPerResource} tags at most.`; } const seen = new Set(); for (const tag of tags) { if (!tag.Key) return 'Every tag needs a key.'; if (tag.Key.length > IAM_LIMITS.tagKeyChars) { return `Tag key "${tag.Key}" must be ${IAM_LIMITS.tagKeyChars} characters or fewer.`; } if (!IAM_LIMITS.tagKeyPattern.test(tag.Key)) { return `Tag key "${tag.Key}" may contain letters, numbers, spaces and _ . : / = + - @ only.`; } if ((tag.Value || '').length > IAM_LIMITS.tagValueChars) { return `Tag value for "${tag.Key}" must be ${IAM_LIMITS.tagValueChars} characters or fewer.`; } if (!IAM_LIMITS.tagValuePattern.test(tag.Value || '')) { return `Tag value for "${tag.Key}" may contain letters, numbers, spaces and _ . : / = + - @ only.`; } const normalized = caseSensitiveKeys ? tag.Key : tag.Key.toLowerCase(); if (seen.has(normalized)) { return caseSensitiveKeys ? `Tag key "${tag.Key}" is listed twice.` : `Tag key "${tag.Key}" is listed twice. Keys are case insensitive.`; } seen.add(normalized); } return null; } /** * Render a tag list as read-only two-tone chips, key alongside value. */ function iamTagChips(tags) { if (!tags.length) return 'No tags'; return tags.map(tag => { const value = tag.Value ? `${escapeHtml(tag.Value)}` : 'empty'; return ` ${escapeHtml(tag.Key)}${value} `; }).join(''); } // ============================================ // Repeatable form rows (tags, client IDs, thumbprints) // ============================================ function iamRemoveRow(button) { const row = button.closest('[data-iam-row]'); if (row) row.remove(); } function iamAddTagRow(containerId, key = '', value = '') { const container = document.getElementById(containerId); if (!container) return; if (container.querySelectorAll('[data-iam-row]').length >= IAM_LIMITS.tagsPerResource) { showToast(`A resource can carry ${IAM_LIMITS.tagsPerResource} tags at most`, 'warning'); return; } const row = document.createElement('div'); row.className = 'flex items-center gap-2'; row.setAttribute('data-iam-row', 'tag'); row.innerHTML = ` `; container.appendChild(row); } function iamCollectTags(containerId) { const container = document.getElementById(containerId); if (!container) return []; return Array.from(container.querySelectorAll('[data-iam-row="tag"]')) .map(row => ({ Key: row.querySelector('[data-tag-key]').value.trim(), Value: row.querySelector('[data-tag-value]').value.trim() })) .filter(tag => tag.Key); } function iamAddTextRow(containerId, value = '', placeholder = '', maxlength = 255) { const container = document.getElementById(containerId); if (!container) return; const row = document.createElement('div'); row.className = 'flex items-center gap-2'; row.setAttribute('data-iam-row', 'text'); row.innerHTML = ` `; container.appendChild(row); } function iamCollectTextRows(containerId) { const container = document.getElementById(containerId); if (!container) return []; return Array.from(container.querySelectorAll('[data-iam-row="text"] [data-row-value]')) .map(input => input.value.trim()) .filter(Boolean); } // ============================================ // Policy editor (identity and trust variants) // ============================================ const IAM_POLICY_VARIANTS = { identity: { infoTitle: 'About Inline Identity Policies', infoBody: 'An inline policy grants the identity it is attached to permission to call specific actions on specific resources. The principal is implicit, so an identity policy must not contain a Principal field.', reference: `

Common Actions:

Resource Format:

`, help: `

Required Fields:

Statement Fields:

Authorization Model:

Each action is resolved as iam:<ActionName> against the concrete target ARN. There is no implicit self-access: a user with no inline policy cannot call GetUser even on itself.

`, example: { Version: '2012-10-17', Statement: [ { Sid: 'ReadOwnIdentity', Effect: 'Allow', Action: ['iam:GetUser', 'iam:ListAccessKeys'], Resource: [`arn:aws:iam::${IAM_ACCOUNT_ID}:user/alice`] }, { Sid: 'ReadObjects', Effect: 'Allow', Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::example-bucket/*'] } ] } }, trust: { infoTitle: 'About Trust Policies', infoBody: 'A trust policy states who may assume this role. Principal is required, Resource is not allowed, and every action must be sts:-prefixed. Shared identity providers are additionally tenancy-scoped by the server; it will describe the rule if the document violates it.', reference: `

Actions:

Principal Keys:

`, help: `

Required Fields:

Not Allowed:

Federated Providers:

Trust statements naming a shared provider (GitHub Actions, GitLab and similar) must scope the condition to your own tenancy. The server validates this and returns a descriptive error when the scoping is missing.

`, example: { Version: '2012-10-17', Statement: [ { Sid: 'AllowUserToAssume', Effect: 'Allow', Principal: { AWS: `arn:aws:iam::${IAM_ACCOUNT_ID}:user/example` }, Action: ['sts:AssumeRole'] } ] } } }; const iamPolicyEditor = { _state: null, _ensureModal() { if (document.getElementById('iam-policy-modal')) return; const wrapper = document.createElement('div'); wrapper.id = 'iam-policy-modal'; wrapper.className = 'modal hidden fixed inset-0 z-50'; wrapper.innerHTML = `

Inline Policy

Quick Reference

`; document.body.appendChild(wrapper); }, /** * @param {Object} opts * variant 'identity' | 'trust' * title modal heading * subtitle the identity this document belongs to * policyName existing name ('' for a new policy) * nameEditable show and require the policy-name field * document initial JSON text * quota { otherBytes, max } for the aggregate inline-policy counter * maxBytes hard limit for this single document * showDelete render the Delete Policy button * saveLabel label for the save button (default 'Save Policy') * onSave async ({ policyName, document }) => void * onDelete async () => void */ open(opts) { this._ensureModal(); const variant = IAM_POLICY_VARIANTS[opts.variant] || IAM_POLICY_VARIANTS.identity; this._state = Object.assign({ variant: 'identity' }, opts); document.getElementById('iam-policy-title').textContent = opts.title || 'Policy'; document.getElementById('iam-policy-subtitle').textContent = opts.subtitle || ''; document.getElementById('iam-policy-info-title').textContent = variant.infoTitle; document.getElementById('iam-policy-info-body').innerHTML = variant.infoBody; document.getElementById('iam-policy-reference').innerHTML = variant.reference; document.getElementById('iam-policy-help-body').innerHTML = variant.help; document.getElementById('iam-policy-help').classList.add('hidden'); const nameRow = document.getElementById('iam-policy-name-row'); const nameInput = document.getElementById('iam-policy-name'); nameRow.classList.toggle('hidden', !opts.nameEditable); nameInput.value = opts.policyName || ''; const editor = document.getElementById('iam-policy-editor-json'); editor.value = opts.document || ''; document.getElementById('iam-policy-delete-btn').classList.toggle('hidden', !opts.showDelete); document.getElementById('iam-policy-save-btn').textContent = opts.saveLabel || 'Save Policy'; this._setStatus(null); this.updateCounter(); openModal('iam-policy-modal'); }, close() { this._state = null; closeModal('iam-policy-modal'); }, toggleHelp() { document.getElementById('iam-policy-help').classList.toggle('hidden'); }, loadExample() { const variant = IAM_POLICY_VARIANTS[this._state?.variant] || IAM_POLICY_VARIANTS.identity; document.getElementById('iam-policy-editor-json').value = JSON.stringify(variant.example, null, 2); this._setStatus(null); this.updateCounter(); }, updateCounter() { const state = this._state; if (!state) return; const bytes = iamByteLength(document.getElementById('iam-policy-editor-json').value); const counter = document.getElementById('iam-policy-counter'); const quota = state.quota; if (quota) { const total = (quota.otherBytes || 0) + bytes; const over = total > quota.max; counter.className = 'mt-2 text-xs ' + (over ? 'text-red-600 font-medium' : 'text-charcoal-300'); counter.textContent = `${total} / ${quota.max} bytes used across this identity's inline policies (this document: ${bytes} bytes)`; } else { const max = state.maxBytes || IAM_LIMITS.policyDocumentBytes; const over = bytes > max; counter.className = 'mt-2 text-xs ' + (over ? 'text-red-600 font-medium' : 'text-charcoal-300'); counter.textContent = `${bytes} / ${max} bytes`; } }, _setStatus(message, type = 'error') { const el = document.getElementById('iam-policy-status'); if (!message) { el.classList.add('hidden'); el.innerHTML = ''; return; } const styles = { error: 'bg-red-50 border-red-200 text-red-800', success: 'bg-green-50 border-green-200 text-green-800', warning: 'bg-yellow-50 border-yellow-200 text-yellow-800' }; el.className = `mb-3 border rounded-lg px-4 py-3 text-sm ${styles[type]}`; el.textContent = message; el.classList.remove('hidden'); }, /** * Client-side grammar check. Deliberately shallow: it catches the two * grammars' hard rules and leaves everything else to the server's own * descriptive errors. */ validate(announce = false) { const state = this._state; if (!state) return null; const text = document.getElementById('iam-policy-editor-json').value.trim(); if (!text) { this._setStatus('Policy document is empty.'); return null; } let parsed; try { parsed = JSON.parse(text); } catch (e) { this._setStatus('Invalid JSON: ' + e.message); return null; } const errors = []; if (!parsed.Version) errors.push('Missing "Version" (use "2012-10-17").'); const statements = Array.isArray(parsed.Statement) ? parsed.Statement : (parsed.Statement ? [parsed.Statement] : []); if (statements.length === 0) errors.push('"Statement" must be a non-empty array.'); statements.forEach((st, i) => { const at = `Statement ${i + 1}`; if (!st || typeof st !== 'object') { errors.push(`${at} must be an object.`); return; } if (st.Effect !== 'Allow' && st.Effect !== 'Deny') errors.push(`${at}: "Effect" must be "Allow" or "Deny".`); const actions = [].concat(st.Action || st.NotAction || []); if (actions.length === 0) errors.push(`${at}: "Action" is required.`); if (state.variant === 'trust') { if (!st.Principal || typeof st.Principal !== 'object' || Array.isArray(st.Principal)) { errors.push(`${at}: "Principal" is required and must be an object with AWS, Service or Federated keys.`); } else { const allowed = ['AWS', 'Service', 'Federated']; Object.keys(st.Principal).forEach(key => { if (!allowed.includes(key)) errors.push(`${at}: "Principal.${key}" is not allowed. Use AWS, Service or Federated.`); }); } if ('Resource' in st || 'NotResource' in st) errors.push(`${at}: "Resource" is not allowed in a trust policy.`); actions.forEach(action => { if (typeof action === 'string' && !action.startsWith('sts:')) errors.push(`${at}: "${action}" is not an sts: action.`); }); } else { if ('Principal' in st || 'NotPrincipal' in st) errors.push(`${at}: "Principal" is not allowed in an identity policy - the identity it is attached to is the principal.`); const resources = [].concat(st.Resource || st.NotResource || []); if (resources.length === 0) errors.push(`${at}: "Resource" is required.`); } }); const bytes = iamByteLength(text); const maxBytes = state.maxBytes || IAM_LIMITS.policyDocumentBytes; if (bytes > maxBytes) errors.push(`Document is ${bytes} bytes, over the ${maxBytes}-byte limit.`); if (state.quota && (state.quota.otherBytes || 0) + bytes > state.quota.max) { errors.push(`This identity's inline policies would total ${(state.quota.otherBytes || 0) + bytes} bytes, over the ${state.quota.max}-byte aggregate limit.`); } if (errors.length > 0) { this._setStatus(errors.join(' ')); return null; } if (announce) this._setStatus('Policy document is valid.', 'success'); else this._setStatus(null); return JSON.stringify(parsed); }, async save() { const state = this._state; if (!state) return; let policyName = state.policyName || ''; if (state.nameEditable) { policyName = document.getElementById('iam-policy-name').value.trim(); const nameError = iamValidateName(policyName, 'Policy name'); if (nameError) { this._setStatus(nameError); return; } } const document_ = this.validate(); if (!document_) return; const btn = document.getElementById('iam-policy-save-btn'); setLoading(btn, true); try { await state.onSave({ policyName, document: document_ }); this.close(); } catch (error) { console.error('Error saving policy:', error); this._setStatus(iamErrorText(error)); } finally { setLoading(btn, false); } }, async remove() { const state = this._state; if (!state || !state.onDelete) return; const btn = document.getElementById('iam-policy-delete-btn'); setLoading(btn, true); try { await state.onDelete(); this.close(); } catch (error) { console.error('Error deleting policy:', error); this._setStatus(iamErrorText(error)); } finally { setLoading(btn, false); } } }; // ============================================ // Tag editor // ============================================ /** * Edit an identity's whole tag set at once, then apply it as the minimal * pair of API calls: one untag for the keys that disappeared, one tag for * the ones added or changed. Editing the set as a whole — rather than a * tag at a time — is what lets a rename, a couple of additions and a couple * of removals be one reviewable Save. */ const iamTagEditor = { _state: null, _ensureModal() { if (document.getElementById('iam-tag-modal')) return; const wrapper = document.createElement('div'); wrapper.id = 'iam-tag-modal'; wrapper.className = 'modal hidden fixed inset-0 z-50'; wrapper.innerHTML = `

Tags

About Tags

`; document.body.appendChild(wrapper); // iamAddTagRow and iamRemoveRow are shared with the create forms and // report nothing back, so watch the container instead of hooking them. new MutationObserver(() => this.updateCounter()) .observe(document.getElementById('iam-tag-rows'), { childList: true }); }, /** * @param {Object} opts * title modal heading * subtitle the resource these tags belong to * tags current tags, as [{Key, Value}] * caseSensitiveKeys compare keys exactly instead of folding their case * onSave async ({ set, remove }) => void, where set holds the * tags to add or overwrite and remove holds the keys to * drop */ open(opts) { this._ensureModal(); this._state = Object.assign({}, opts); document.getElementById('iam-tag-title').textContent = opts.title || 'Tags'; document.getElementById('iam-tag-subtitle').textContent = opts.subtitle || ''; document.getElementById('iam-tag-about').innerHTML = 'Key/value labels for grouping and search. They are also readable from policy conditions as ' + 'aws:PrincipalTag/<key> for the tagged identity and ' + 'aws:ResourceTag/<key> for the resource being acted on. ' + (opts.caseSensitiveKeys ? 'Keys are case sensitive, so "env" and "ENV" are separate tags; values may be empty.' : 'Keys are case insensitive; values may be empty.'); const rows = document.getElementById('iam-tag-rows'); rows.innerHTML = ''; (opts.tags || []).forEach(tag => iamAddTagRow('iam-tag-rows', tag.Key, tag.Value || '')); this._setStatus(null); this.updateCounter(); openModal('iam-tag-modal'); }, close() { this._state = null; closeModal('iam-tag-modal'); }, addRow() { iamAddTagRow('iam-tag-rows'); const rows = document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]'); const last = rows[rows.length - 1]; if (last) last.querySelector('[data-tag-key]').focus(); }, updateCounter() { const rows = document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]').length; document.getElementById('iam-tag-empty').classList.toggle('hidden', rows > 0); const counter = document.getElementById('iam-tag-counter'); const over = rows > IAM_LIMITS.tagsPerResource; counter.className = 'mt-3 text-xs ' + (over ? 'text-red-600 font-medium' : 'text-charcoal-300'); counter.textContent = `${rows} / ${IAM_LIMITS.tagsPerResource} tags`; }, _setStatus(message) { const el = document.getElementById('iam-tag-status'); if (!message) { el.classList.add('hidden'); el.textContent = ''; return; } el.className = 'border rounded-lg px-4 py-3 text-sm bg-red-50 border-red-200 text-red-800'; el.textContent = message; el.classList.remove('hidden'); }, /** * Diff the edited rows against the tags the modal opened with. Where keys * fold, a key whose only change is its casing still lands in set: the tag * action overwrites the stored tag in place, taking the new casing with * it. Where keys are exact, that same edit is a removal plus an addition. */ _diff(current) { const original = this._state.tags || []; const key = tag => (this._state.caseSensitiveKeys ? tag.Key : tag.Key.toLowerCase()); const originalByKey = new Map(original.map(tag => [key(tag), tag])); const currentKeys = new Set(current.map(key)); const set = current.filter(tag => { const before = originalByKey.get(key(tag)); return !before || before.Key !== tag.Key || (before.Value || '') !== (tag.Value || ''); }); const remove = original .filter(tag => !currentKeys.has(key(tag))) .map(tag => tag.Key); return { set, remove }; }, async save() { const state = this._state; if (!state) return; // iamCollectTags drops keyless rows, so a value typed without a key // would silently vanish. Catch that before it does. const orphanValue = Array.from(document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]')) .some(row => !row.querySelector('[data-tag-key]').value.trim() && row.querySelector('[data-tag-value]').value.trim()); if (orphanValue) { this._setStatus('Every tag needs a key.'); return; } const current = iamCollectTags('iam-tag-rows'); const error = iamValidateTags(current, state.caseSensitiveKeys); if (error) { this._setStatus(error); return; } const { set, remove } = this._diff(current); if (!set.length && !remove.length) { showToast('No tag changes to save', 'info'); this.close(); return; } const btn = document.getElementById('iam-tag-save-btn'); setLoading(btn, true); try { await state.onSave({ set, remove }); this.close(); } catch (err) { console.error('Error saving tags:', err); this._setStatus(iamErrorText(err)); } finally { setLoading(btn, false); } } };