mirror of
https://github.com/versity/versitygw.git
synced 2026-09-28 02:44:29 +00:00
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:
+78
-11
@@ -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 || '';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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/<key></code> for the tagged user and <code class="font-mono">aws:ResourceTag/<key></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/<key></code> for the tagged identity and <code class="font-mono">aws:ResourceTag/<key></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 || [];
|
||||
|
||||
Reference in New Issue
Block a user