diff --git a/frontend/src/__tests__/group-edit-unrepresentable-permissions.test.ts b/frontend/src/__tests__/group-edit-unrepresentable-permissions.test.ts index 90274ba16..57d6bd996 100644 --- a/frontend/src/__tests__/group-edit-unrepresentable-permissions.test.ts +++ b/frontend/src/__tests__/group-edit-unrepresentable-permissions.test.ts @@ -49,9 +49,19 @@ jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn().mockResolvedValue(true), })); +// Only the toast functions are stubbed; escapeHtml and the rest stay real, +// because the escaping tests below depend on the genuine implementation. +jest.mock('../users/utils', () => ({ + ...jest.requireActual('../users/utils'), + showError: jest.fn(), + showSuccess: jest.fn(), +})); + import * as api from '../api'; import * as groupState from '../groups/state'; import * as groupModals from '../groups/groupModals'; +import { setupGroupHandlers } from '../groups/handlers'; +import { showError } from '../users/utils'; import { ALL_ACTIONS, ALL_RESOURCES } from '../permissions'; // The exact seeded Purchaser group permission set (PURCHASER_PERMS in @@ -284,3 +294,404 @@ describe('regression: group edit no longer drops/widens unrepresentable permissi }); }); }); + +/** + * The constraints axis of the same defect (#1629). + * + * PermissionConstraints (internal/auth/types.go) carries five dimensions; + * the form rendered inputs for four. constraints.accounts had nowhere to + * live and collectPermissions() never read one back, so every save dropped + * it. That WIDENS the permission: matchStringListConstraints + * (internal/auth/service_group.go) treats an empty list as "no restriction + * on this dimension", so a permission stored as "manage any scheduled + * purchase, but only in acct-prod-1" came back out of a cosmetic rename as + * "manage any scheduled purchase, in every cloud account". The backend + * grant ceiling does not catch it either: grantCeilingAllows short-circuits + * on the caller's admin:*. + * + * These tests drive the REAL save path -- the submit listener installed by + * setupGroupHandlers(), reached by clicking the form's submit button -- + * rather than calling saveGroup(event) directly. #group-form carries no + * novalidate and .perm-resource is `required`, so a direct call bypasses + * browser constraint validation and cannot tell whether a fix is on the + * path the admin actually uses. + */ +describe('regression: group edit preserves constraints.accounts (#1629)', () => { + // Mirrors the #group-form markup in index.html: required name input, the + // permissions list, and a real submit button. + function setUpRealFormDom(): void { + document.body.innerHTML = ` + + `; + setupGroupHandlers(); + } + + function submitButton(): HTMLButtonElement { + return document.querySelector('#group-form button[type="submit"]') as HTMLButtonElement; + } + + // Clicks Save the way an admin does and waits for saveGroup's promise + // chain to settle (the submit listener fires it without awaiting). + async function clickSave(): Promise { + submitButton().click(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + + async function openEdit(group: api.APIGroup): Promise { + (api.getGroup as jest.Mock).mockResolvedValue(group); + await groupModals.openEditGroupModal(group.id); + } + + function accountsInput(index = 0): HTMLInputElement { + const items = document.querySelectorAll('.permission-item'); + return items[index]!.querySelector('.perm-accounts') as HTMLInputElement; + } + + function savedPermissions(): api.Permission[] { + const call = (api.updateGroup as jest.Mock).mock.calls[0]; + return call[1].permissions as api.Permission[]; + } + + // A realistic account-scoped operator group. The second permission is the + // narrowest form of the bug: accounts is its ONLY constraint, so the old + // `if (providers || services || regions || maxAmount)` guard never fired + // and the permission round-tripped fully unconstrained. + const ACCOUNT_SCOPED_PERMISSIONS: api.Permission[] = [ + { action: 'update-any', resource: 'purchases', constraints: { accounts: ['acct-prod-1'], max_amount: 5000 } }, + { action: 'view', resource: 'history', constraints: { accounts: ['acct-prod-1'] } }, + ]; + + function accountScopedGroup(): api.APIGroup { + return { + id: 'operators-group-id', + name: 'Prod Operators', + description: 'Old description', + permissions: ACCOUNT_SCOPED_PERMISSIONS, + created_at: '2024-01-01T00:00:00Z', + }; + } + + beforeEach(() => { + setUpRealFormDom(); + groupState.setCurrentEditingGroup(null); + jest.clearAllMocks(); + }); + + test('the harness really goes through browser constraint validation', async () => { + // Pins the property the rest of this describe relies on: an invalid form + // never reaches saveGroup. Without this, a fix that never runs could pass + // every test below. + await openEdit(accountScopedGroup()); + (document.getElementById('group-name') as HTMLInputElement).value = ''; + + const form = document.getElementById('group-form') as HTMLFormElement; + expect(form.checkValidity()).toBe(false); + await clickSave(); + + expect(api.updateGroup).not.toHaveBeenCalled(); + }); + + test('an account-scoped permission set survives an edit that only changes the description', async () => { + await openEdit(accountScopedGroup()); + (document.getElementById('group-description') as HTMLTextAreaElement).value = 'New description'; + + const form = document.getElementById('group-form') as HTMLFormElement; + expect(form.checkValidity()).toBe(true); + await clickSave(); + + expect(api.updateGroup).toHaveBeenCalledWith('operators-group-id', { + name: 'Prod Operators', + description: 'New description', + permissions: ACCOUNT_SCOPED_PERMISSIONS, + }); + + // Non-vacuity: "no permission lost its fence" is trivially true of an + // empty list, and an empty list is itself one of this bug's outcomes. + const saved = savedPermissions(); + expect(saved).toHaveLength(2); + expect(saved.every(p => (p.constraints?.accounts?.length ?? 0) > 0)).toBe(true); + }); + + test('the stored account fence is rendered into the form, so the admin edits what is actually stored', async () => { + await openEdit(accountScopedGroup()); + + expect(accountsInput(0).value).toBe('acct-prod-1'); + expect(accountsInput(1).value).toBe('acct-prod-1'); + }); + + test('narrowing the fence saves exactly what was entered', async () => { + const group = accountScopedGroup(); + group.permissions = [ + { action: 'update-any', resource: 'purchases', constraints: { accounts: ['acct-prod-1', 'acct-prod-2'] } }, + ]; + await openEdit(group); + + accountsInput().value = 'acct-prod-1'; + await clickSave(); + + expect(savedPermissions()).toEqual([ + { action: 'update-any', resource: 'purchases', constraints: { accounts: ['acct-prod-1'] } }, + ]); + }); + + test('clearing one fence removes only that one and is not blocked', async () => { + // The other direction: an admin who deliberately clears the field gets an + // unconstrained permission, which is what they asked for. + // + // Only the FIRST row is cleared, and the second is asserted to keep its + // fence. "The cleared row has no accounts" is on its own true of a build + // that renders the input but never reads it back -- the very defect this + // file exists for -- so the assertion is paired with one that such a build + // fails. Both are keyed on the outgoing payload rather than on any DOM + // hook this change introduces. + await openEdit(accountScopedGroup()); + + accountsInput(0).value = ''; + await clickSave(); + + const saved = savedPermissions(); + expect(saved).toHaveLength(2); + expect(saved[0]!.constraints?.accounts).toBeUndefined(); + expect(saved[0]!.constraints?.max_amount).toBe(5000); + expect(saved[1]!.constraints?.accounts).toEqual(['acct-prod-1']); + }); + + test('adding a fence to a previously unconstrained permission saves it', async () => { + const group = accountScopedGroup(); + group.permissions = [{ action: 'view', resource: 'history' }]; + await openEdit(group); + + accountsInput().value = 'acct-prod-1, acct-prod-2'; + await clickSave(); + + expect(savedPermissions()).toEqual([ + { action: 'view', resource: 'history', constraints: { accounts: ['acct-prod-1', 'acct-prod-2'] } }, + ]); + }); + + test('an account id is escaped on the way into the input and round-trips byte-identically', async () => { + // The new value="" attribute is another API string reaching innerHTML. + const payload = '">'; + const group = accountScopedGroup(); + group.permissions = [{ action: 'view', resource: 'history', constraints: { accounts: [payload] } }]; + await openEdit(group); + + expect(document.querySelectorAll('img, script, svg, iframe, style').length).toBe(0); + expect(accountsInput().value).toBe(payload); + + await clickSave(); + + expect(savedPermissions()).toEqual([ + { action: 'view', resource: 'history', constraints: { accounts: [payload] } }, + ]); + }); + + /** + * Raised by CodeRabbit on PR #1875 and taken rather than dismissed. + * + * Representing `accounts` fixes the common case but leaves one open: a + * value the comma-separated text encoding cannot carry back unchanged. A + * stored [""] renders blank and re-parses as ABSENT; [","] re-parses as an + * empty list; [" acct A "] comes back trimmed. Each re-submits a different + * restriction than the one loaded, and for a constraint list "different" + * means WIDER, because an empty list is "no restriction" at enforcement. + * The backend guard cannot catch it either: the form filters the value out + * before the request is built, so validateConstraintEntries never sees a + * blank to reject. + * + * The fix detects it when the row renders and refuses the save. A loud + * refusal beats a silent widening. + * + * Every assertion here is keyed on the outgoing payload or on the error + * message, never on the `data-unrepresentable` attribute the fix + * introduces: a negative keyed on a hook that does not exist pre-fix can + * never fail pre-fix. + */ + describe('refuses to save a stored constraint value it cannot represent', () => { + function groupWithAccounts(accounts: string[]): api.APIGroup { + return { + id: 'operators-group-id', + name: 'Prod Operators', + description: 'Old description', + permissions: [{ action: 'view', resource: 'history', constraints: { accounts } }], + created_at: '2024-01-01T00:00:00Z', + }; + } + + // Each of these vanishes or mutates through parseConstraintList. The + // second is the case a per-entry "is it blank" check would miss: "," is + // not blank, but the split runs before the filter, so it still vanishes. + const UNREPRESENTABLE: Array<[string, string[]]> = [ + ['a blank entry (deny-everything becomes allow-everything)', ['']], + ['a comma-only entry, which is not blank but still vanishes', [',']], + ['a whitespace-padded entry, which would come back as a different fence', [' acct A ']], + ['a blank entry beside a real one', ['acct-prod-1', ' ']], + ]; + + test.each(UNREPRESENTABLE)('%s refuses the save', async (_label, accounts) => { + await openEdit(groupWithAccounts(accounts)); + (document.getElementById('group-description') as HTMLTextAreaElement).value = 'New description'; + + // The form is valid, so the browser submits and the handler runs: the + // refusal is this code's decision, not constraint validation's. + const form = document.getElementById('group-form') as HTMLFormElement; + expect(form.checkValidity()).toBe(true); + await clickSave(); + + expect(api.updateGroup).not.toHaveBeenCalled(); + expect(showError).toHaveBeenCalledTimes(1); + const message = (showError as jest.Mock).mock.calls[0][0] as string; + // Names which permission and which constraint list, matching the + // specificity of the backend refusal. + expect(message).toContain('permission 0'); + expect(message).toContain('view:history'); + expect(message).toContain('accounts'); + }); + + test('names every offending permission, by index, when several are unsafe', async () => { + const group = groupWithAccounts(['']); + group.permissions = [ + { action: 'view', resource: 'plans' }, + { action: 'view', resource: 'history', constraints: { accounts: [''] } }, + { action: 'update-any', resource: 'purchases', constraints: { regions: [' us-east-1 '] } }, + ]; + await openEdit(group); + await clickSave(); + + expect(api.updateGroup).not.toHaveBeenCalled(); + const message = (showError as jest.Mock).mock.calls[0][0] as string; + expect(message).toContain('permission 1 (view:history)'); + expect(message).toContain('permission 2 (update-any:purchases)'); + expect(message).toContain('regions'); + // The clean row is not blamed. + expect(message).not.toContain('view:plans'); + }); + + test('removing the offending row unblocks the save', async () => { + // The verdict lives on the row, so deleting the row clears it. Removing + // the permission outright is an explicit edit, not a silent drop. + await openEdit(groupWithAccounts([''])); + const removeBtn = document.querySelector('.permission-item .remove-permission-btn') as HTMLButtonElement; + removeBtn.click(); + await clickSave(); + + expect(showError).not.toHaveBeenCalled(); + expect(api.updateGroup).toHaveBeenCalledWith('operators-group-id', { + name: 'Prod Operators', + description: 'Old description', + permissions: [], + }); + }); + + // Negative controls, payload-keyed: the refusal must not fire on the + // ordinary constraint shapes this form exists to edit. An absent or empty + // list both mean "no restriction" and are entirely normal. + const REPRESENTABLE: Array<[string, api.Permission]> = [ + ['a populated list', { action: 'view', resource: 'history', constraints: { accounts: ['acct-prod-1', 'acct-prod-2'] } }], + ['an empty list', { action: 'view', resource: 'history', constraints: { accounts: [] } }], + ['no constraints at all', { action: 'view', resource: 'history' }], + ['a non-list constraint only', { action: 'view', resource: 'history', constraints: { max_amount: 5000 } }], + ]; + + test.each(REPRESENTABLE)('%s still saves', async (_label, permission) => { + const group = groupWithAccounts([]); + group.permissions = [permission]; + await openEdit(group); + await clickSave(); + + expect(showError).not.toHaveBeenCalled(); + expect(api.updateGroup).toHaveBeenCalledTimes(1); + }); + + /** + * The same widening from the typed direction, CodeRabbit's second finding + * on this PR. The render-time check above covers a STORED value; this + * covers what an operator types. + * + * "," is non-empty in the box but parseConstraintList reduces it to [], + * so the save would send an empty list, and an empty list is "no + * restriction" at enforcement. A stray comma would silently remove the + * fence. This is MORE reachable than the stored case, which needs data no + * shipped code produces; this one needs a typo. + */ + describe('refuses typed input that parses to nothing', () => { + const TYPED_NOTHING: Array<[string, string]> = [ + ['a bare comma', ','], + ['a comma padded with spaces', ' , '], + ['several commas', ',,,'], + ]; + + test.each(TYPED_NOTHING)('%s refuses the save and names the field', async (_label, typed) => { + await openEdit(groupWithAccounts(['acct-prod-1'])); + accountsInput().value = typed; + await clickSave(); + + expect(api.updateGroup).not.toHaveBeenCalled(); + expect(showError).toHaveBeenCalledTimes(1); + const message = (showError as jest.Mock).mock.calls[0][0] as string; + expect(message).toContain('permission 0'); + expect(message).toContain('view:history'); + expect(message).toContain('accounts'); + }); + + test('names the offending dimension, not a different one', async () => { + const group = groupWithAccounts(['acct-prod-1']); + group.permissions = [{ action: 'view', resource: 'history', constraints: { regions: ['us-east-1'] } }]; + await openEdit(group); + (document.querySelector('.perm-regions') as HTMLInputElement).value = ','; + await clickSave(); + + expect(api.updateGroup).not.toHaveBeenCalled(); + const message = (showError as jest.Mock).mock.calls[0][0] as string; + expect(message).toContain('regions'); + expect(message).not.toContain('accounts'); + }); + + // The boundary that must not move: a box the operator empties still + // means "no restriction" and must save. Refusing here would make every + // unconstrained group uneditable, which is worse than the bug. + test('an emptied box still saves as no restriction', async () => { + await openEdit(groupWithAccounts(['acct-prod-1'])); + accountsInput().value = ''; + await clickSave(); + + expect(showError).not.toHaveBeenCalled(); + expect(savedPermissions()).toEqual([{ action: 'view', resource: 'history' }]); + }); + + // A box holding only spaces looks identical to an empty one on screen, + // so it is treated as empty rather than refused: an error on a field + // that appears blank could not be acted on. + test('a whitespace-only box is treated as emptied, not refused', async () => { + await openEdit(groupWithAccounts(['acct-prod-1'])); + accountsInput().value = ' '; + await clickSave(); + + expect(showError).not.toHaveBeenCalled(); + expect(savedPermissions()).toEqual([{ action: 'view', resource: 'history' }]); + }); + + // A trailing comma is ordinary typing and still yields a usable entry, + // so it must save rather than trip the refusal. + test('a trailing comma beside a real value still saves', async () => { + await openEdit(groupWithAccounts(['acct-prod-1'])); + accountsInput().value = 'acct-prod-1, acct-prod-2,'; + await clickSave(); + + expect(showError).not.toHaveBeenCalled(); + expect(savedPermissions()).toEqual([ + { action: 'view', resource: 'history', constraints: { accounts: ['acct-prod-1', 'acct-prod-2'] } }, + ]); + }); + }); + }); +}); diff --git a/frontend/src/groups/groupModals.ts b/frontend/src/groups/groupModals.ts index 92d9486ed..b9e8bfae7 100644 --- a/frontend/src/groups/groupModals.ts +++ b/frontend/src/groups/groupModals.ts @@ -86,6 +86,22 @@ export function closeGroupModal(): void { export async function saveGroup(e: Event): Promise { e.preventDefault(); + // Refuse rather than widen. A stored constraint value this form cannot + // carry unchanged is flagged when the row is rendered; saving anyway would + // re-submit a materially different permission set than the one loaded, and + // for a constraint list that means an unintended widening (an empty list is + // "no restriction" at enforcement). The group stays uneditable through the + // form until the value is repaired via the API or in the database. + const blocked = unrepresentablePermissionErrors(); + if (blocked.length > 0) { + showError( + `Cannot save: ${blocked.join('; ')}. ` + + 'Saving would silently drop the restriction, so the edit is refused. ' + + 'Repair the value through the API or in the database, then reload this page.' + ); + return; + } + const name = (document.getElementById('group-name') as HTMLInputElement).value; const description = (document.getElementById('group-description') as HTMLTextAreaElement).value; const permissions = collectPermissions(); @@ -204,6 +220,18 @@ export function addPermission(permission?: Permission): void { const permDiv = document.createElement('div'); permDiv.className = 'permission-item'; + + // Record the verdict, not the value: which of this row's constraint lists + // hold something the text encoding cannot carry back unchanged. saveGroup + // refuses while any row carries this. The attribute lives and dies with the + // row, so removing the row clears it and nothing outlives the modal. The + // action:resource label is read live from the selects at refusal time rather + // than stored here, so it cannot go stale against what the operator sees. + const unsafe = unrepresentableDimensions(permission?.constraints); + if (unsafe.length > 0) { + permDiv.setAttribute('data-unrepresentable', unsafe.join(', ')); + } + permDiv.innerHTML = `

Constraints (Optional)

+
+ +