feat: add IAM role tagging actions

Adds `TagRole`, `UntagRole` and `ListRoleTags` to the standalone IAM service, backed by both the internal and Vault storers, with the same semantics the user tagging actions already have: tag keys are matched case-insensitively but stored case-preserving, TagRole merges into the role's existing tags and rejects duplicate keys, UntagRole removal is idempotent, and ListRoleTags is sorted by key and paginated. The per-request member count and the per-role tag total are enforced as separate quotas, so replacing a tag on a role already at the 50-tag cap still succeeds. Role tags and the tags of a user sharing the same name are independent sets.

All three actions are authorized against the target role's ARN, so `aws:ResourceTag/<key>` reads the role's own tags, and TagRole and UntagRole populate `aws:RequestTag/<key>` and `aws:TagKeys` respectively, so a tag-scoped policy Condition governs which tags a caller may set or remove.

The tag storage helpers are now shared between users and roles: `MaxTagsPerUser` becomes `MaxTagsPerResource`, `ListUserTagsOutput` becomes `ListTagsOutput`, and `paginateTags` takes the marker and page size directly instead of a user-specific input struct.

The WebGUI gains a Tags section in the IAM role manage view, reusing the tag editor the user view already uses, which applies a whole edited tag set as a single UntagRole and TagRole pair.

Also corrects the `roleName` length bound across every role action: it was validated against the 128-character user-lookup limit, where IAM caps role names at 64.
This commit is contained in:
niksis02
2026-08-28 00:49:20 +04:00
parent 26a54b33e9
commit 1bbcd64195
22 changed files with 1947 additions and 82 deletions
+78 -11
View File
@@ -230,7 +230,7 @@ under the License.
<button type="button" onclick="iamAddTagRow('create-role-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
</div>
<div id="create-role-tags" class="space-y-2"></div>
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only.</p>
<p class="mt-2 text-xs text-charcoal-300">Optional. Tags can also be added, changed and removed later from the role’s Manage view.</p>
</div>
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
@@ -302,13 +302,21 @@ under the License.
<dt class="text-charcoal-300" title="Set at creation, not editable">Description</dt>
<dd id="role-detail-description" class="mt-1 text-charcoal">-</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-charcoal-300">Tags</dt>
<dd id="role-detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
</div>
</dl>
</div>
<!-- Tags -->
<div>
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-charcoal">Tags</h3>
<p id="role-tag-quota-note" class="text-xs text-charcoal-300 mt-1">Key/value labels, also readable from policy conditions.</p>
</div>
<button id="edit-role-tags-btn" onclick="openRoleTagEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Edit Tags</button>
</div>
<div id="role-tags" class="border border-gray-100 rounded-lg p-4 flex flex-wrap gap-2"></div>
</div>
<!-- Trust policy -->
<div>
<div class="flex items-center justify-between mb-3">
@@ -382,6 +390,7 @@ under the License.
let nextMarker = null;
let currentRole = null;
let rolePolicySizes = {};
let currentRoleTags = []; // the open role's tags, as [{Key, Value}]
let roleToDelete = null;
let pendingRoleDetails = null; // step 1 of the create wizard
@@ -559,10 +568,12 @@ under the License.
async function openManageRoleModal(roleName) {
currentRole = allRoles.find(r => r.RoleName === roleName) || { RoleName: roleName };
rolePolicySizes = {};
currentRoleTags = [];
document.getElementById('manage-role-title').textContent = roleName;
renderRoleDetails(currentRole);
openModal('manage-role-modal');
loadRoleTags();
// Refresh from the server so the trust document is current
try {
@@ -586,12 +597,6 @@ under the License.
: '-';
document.getElementById('role-detail-description').textContent = role.Description || '-';
const tagsEl = document.getElementById('role-detail-tags');
const tags = Array.isArray(role.Tags) ? role.Tags : (role.Tags ? [role.Tags] : []);
tagsEl.innerHTML = tags.length === 0
? '<span class="text-charcoal-300">-</span>'
: tags.map(tag => `<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`).join('');
const preview = document.getElementById('role-trust-preview');
const trust = role.AssumeRolePolicyDocument || '';
if (!trust) {
@@ -605,6 +610,68 @@ under the License.
}
}
// ============================================
// Manage: tags
// ============================================
/**
* ListRoleTags is its own permission, so this loads the tags rather than
* reusing whatever GetRole happened to return — and a denial disables
* editing in place instead of failing the whole modal.
*/
async function loadRoleTags() {
const el = document.getElementById('role-tags');
el.innerHTML = '<span class="text-sm text-charcoal-300">Loading...</span>';
try {
currentRoleTags = [];
let marker = null;
do {
const page = await api.iamListRoleTags(currentRole.RoleName, { marker: marker || undefined });
currentRoleTags = currentRoleTags.concat(page.tags);
marker = page.isTruncated ? page.marker : null;
} while (marker);
el.innerHTML = iamTagChips(currentRoleTags);
setEditRoleTagsEnabled(true);
updateRoleTagQuotaNote();
} catch (error) {
console.error('Error loading tags:', error);
setEditRoleTagsEnabled(false);
el.innerHTML = iamIsAccessDenied(error)
? '<span class="text-sm text-charcoal-300">You don\u2019t have permission to list this role\u2019s tags</span>'
: `<span class="text-sm text-charcoal-300">Error loading tags: ${escapeHtml(iamShortError(error))}</span>`;
}
}
function setEditRoleTagsEnabled(enabled) {
const button = document.getElementById('edit-role-tags-btn');
button.disabled = !enabled;
button.className = enabled
? 'px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors'
: 'px-3 py-1.5 text-xs border border-gray-200 text-charcoal-300 rounded-lg opacity-50 cursor-not-allowed';
}
function updateRoleTagQuotaNote() {
document.getElementById('role-tag-quota-note').textContent =
`${currentRoleTags.length} / ${IAM_LIMITS.tagsPerResource} tags. Also readable from policy conditions.`;
}
function openRoleTagEditor() {
iamTagEditor.open({
title: 'Edit Tags',
subtitle: `Role ${currentRole.RoleName}`,
tags: currentRoleTags,
onSave: async ({ set, remove }) => {
// Removals first: they free room under the 50-tag cap for whatever
// this same edit is adding.
if (remove.length) await api.iamUntagRole(currentRole.RoleName, remove);
if (set.length) await api.iamTagRole(currentRole.RoleName, set);
showToast('Tags updated successfully', 'success');
loadRoleTags();
}
});
}
function openTrustPolicyEditor() {
if (!currentRole) return;
let documentText = currentRole.AssumeRolePolicyDocument || '';
+30
View File
@@ -2631,6 +2631,36 @@ ${tagsXml}
await this.iamRequest('UpdateAssumeRolePolicy', { RoleName: roleName, PolicyDocument: policyDocument });
}
// ---- Role tags ----
/**
* Add or replace tags on a role. A key already present is overwritten
* rather than duplicated, so this doubles as the edit path.
*/
async iamTagRole(roleName, tags) {
const params = { RoleName: roleName };
this.flattenTags(params, tags);
await this.iamRequest('TagRole', params);
}
async iamUntagRole(roleName, tagKeys) {
const params = { RoleName: roleName };
this.flattenMemberList(params, 'TagKeys', tagKeys);
await this.iamRequest('UntagRole', params);
}
async iamListRoleTags(roleName, options = {}) {
const params = { RoleName: roleName };
if (options.marker) params.Marker = options.marker;
if (options.maxItems) params.MaxItems = options.maxItems;
const result = await this.iamRequest('ListRoleTags', params);
return {
tags: iamAsArray(result.Tags),
isTruncated: result.IsTruncated === 'true',
marker: result.Marker || null
};
}
/**
* Roles echo their trust policy percent-encoded on create/get/list
*/
+6 -6
View File
@@ -173,7 +173,7 @@ function iamValidatePath(path) {
*/
function iamValidateTags(tags) {
if (tags.length > IAM_LIMITS.tagsPerResource) {
return `A user can carry ${IAM_LIMITS.tagsPerResource} tags at most.`;
return `A single resource can carry ${IAM_LIMITS.tagsPerResource} tags at most.`;
}
const seen = new Set();
@@ -697,8 +697,8 @@ const iamPolicyEditor = {
// ============================================
/**
* Edit a user's whole tag set at once, then apply it as the minimal pair of
* API calls: one UntagUser for the keys that disappeared, one TagUser for
* 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.
@@ -730,7 +730,7 @@ const iamTagEditor = {
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div class="text-sm text-blue-800">
<p class="font-medium">About Tags</p>
<p class="mt-1">Key/value labels for grouping and search. They are also readable from policy conditions as <code class="font-mono">aws:PrincipalTag/&lt;key&gt;</code> for the tagged user and <code class="font-mono">aws:ResourceTag/&lt;key&gt;</code> for the user being acted on. Keys are case insensitive; values may be empty.</p>
<p class="mt-1">Key/value labels for grouping and search. They are also readable from policy conditions as <code class="font-mono">aws:PrincipalTag/&lt;key&gt;</code> for the tagged identity and <code class="font-mono">aws:ResourceTag/&lt;key&gt;</code> for the identity being acted on. Keys are case insensitive; values may be empty.</p>
</div>
</div>
</div>
@@ -820,8 +820,8 @@ const iamTagEditor = {
/**
* Diff the edited rows against the tags the modal opened with. A key whose
* only change is its casing still lands in set: TagUser overwrites the
* stored tag in place, taking the new casing with it.
* only change is its casing still lands in set: the tag action overwrites
* the stored tag in place, taking the new casing with it.
*/
_diff(current) {
const original = this._state.tags || [];