// 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: `
Sid: Statement identifier (optional, but recommended)
Effect: "Allow" or "Deny"
Action: Array of actions (e.g., ["iam:GetUser"])
Resource: Array of ARNs the actions apply to
Principal: Not allowed in an identity policy
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:
• sts:AssumeRole
• sts:AssumeRoleWithWebIdentity
Principal Keys:
• AWS - a user ARN in this account
• Federated - an OIDC provider ARN
• Service - a service principal
`,
help: `
Required Fields:
Version: Always "2012-10-17"
Statement: Array of trust statements
Principal: Object with AWS, Service or Federated keys only
Action: sts: actions only
Not Allowed:
Resource and NotResource - the role itself is the resource
Non-sts actions
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.
Up to 64 characters. Letters, numbers and + = , . @ _ - only. The name cannot be changed after saving.
Quick Reference
Policy Structure Guide
`;
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
No tags yet. Use Add Tag to create one.
`;
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);
}
}
};