Protected environment settings silently discard failed updates
Problem
In Settings > CI/CD > Protected environments, deleting an approver or a deployer sends the request, discards any error, and leaves the UI looking unchanged. The user gets no indication that anything failed.
updateEnvironment catches every rejection and commits only
RECEIVE_UPDATE_PROTECTED_ENVIRONMENT_ERROR, whose mutation sets
state.loading = false and nothing else. Every failure class behaves this way:
422, 403, 412, 500, and network errors.
This is reachable today with no unusual configuration. The
deploy_access_levels minimum-of-one validation has always rejected removing
the last deployer, so that action is already a silent no-op. Confirmed by
measurement against a freshly-fetched record:
update result: false
errors: ["Deploy access levels is too short (minimum is 1 character)"]A second, related defect: updateRule commits RECEIVE_RULE_UPDATED in its
.then regardless of outcome, because updateEnvironment swallows the
rejection and resolves. That mutation sets editingRules[rule.id] = null, which
closes the edit form and discards whatever the user typed.
This is not a security issue
Recording this so it is not re-escalated. state.protectedEnvironments is
mutated in only three places, all in
ee/app/assets/javascripts/protected_environments/store/edit/mutations.js:
line 9 (initial fetch), lines 42-43 (..._SUCCESS), and line 70
(DELETE_PROTECTED_ENVIRONMENT_SUCCESS). REQUEST_UPDATE_PROTECTED_ENVIRONMENT
only sets loading = true.
There is no optimistic update, so on failure the list keeps rendering the server's actual state and the entry remains visibly listed. The pending-new- approver picker is likewise only cleared on success. The UI never displays a weaker state than reality, so an operator cannot be misled into believing access was revoked when it was not. The failure mode is a missing explanation, not a misrepresentation.
Steps to reproduce
- Protect an environment with exactly one entry under Allowed to deploy.
- Go to Settings > CI/CD > Protected environments and expand it.
- Delete that sole deployer entry.
- The request returns
422. Nothing happens in the UI, and no error appears.
Proposed fix
Three changes in
ee/app/assets/javascripts/protected_environments/store/edit/actions.js.
-
Add a module-private helper that reads the API's message, handling both the array shape (
render_api_error!witherrors.full_messages) and the plain string shape (destroy_conditionally!), falling back to an existing translated string solocale/gitlab.potneeds no regeneration:const apiErrorMessage = (error) => [] .concat(error?.response?.data?.message ?? __('Something went wrong. Please try again.')) .join(', '); -
In
updateEnvironment, callcreateAlert({ message: apiErrorMessage(error) })in the.catch, and returntruefrom the success path andfalsefrom the failure path so callers can tell the difference. -
In
updateRule, early-return whenupdateEnvironmentresolvesfalse, so the edit form stays open. Use an identity check (=== false), not truthiness -spec/frontend/__helpers__/vuex_action_helper.jsmocksdispatchto resolveundefined, so!updatedwould early-return and break the existingupdateRuletest. -
Apply the same
createAlerttounprotectEnvironment, which has the identical silent-failure behaviour on a path the docs actively direct users toward.
Tests
updateRulekeeps the form open on rejection: drive it withdispatch: () => Promise.resolve(false)and assertcommitis never called.updateEnvironmentalerts the API's message on422with{ message: [reason] }.updateEnvironmentalerts the generic fallback on500.unprotectEnvironmentalerts on failure. Use the string message shape ({ message: '404 Not Found' }) - that endpoint goes throughdestroy_conditionally!and returns204, or412/404with a string. It never returns a422with a model-error array. Note there is noHTTP_STATUS_PRECONDITION_FAILEDconstant inapp/assets/javascripts/lib/utils/http_status.js.
Verification
node_modules/.bin/jest --config jest.config.js ee/spec/frontend/protected_environmentsExpect 10 suites and 154 tests passing. Also run eslint and prettier --check
on both changed files.
Working implementation
Already written and verified against master at 24d47ccc9ad1: 2 files,
+72/-7, jest 154/154, ESLint, Prettier and danger commit-lint clean. Apply
with git apply.
Complete diff
diff --git a/ee/app/assets/javascripts/protected_environments/store/edit/actions.js b/ee/app/assets/javascripts/protected_environments/store/edit/actions.js
index 6b0e5a50c62e..e2da727231e8 100644
--- a/ee/app/assets/javascripts/protected_environments/store/edit/actions.js
+++ b/ee/app/assets/javascripts/protected_environments/store/edit/actions.js
@@ -1,6 +1,8 @@
import { getUser, getProjectMembers, getGroupMembers } from '~/rest_api';
import Api from 'ee/api';
+import { createAlert } from '~/alert';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
import {
INHERITED_GROUPS,
NON_INHERITED_GROUPS,
@@ -9,6 +11,11 @@ import {
} from '../../constants';
import * as types from './mutation_types';
+const apiErrorMessage = (error) =>
+ []
+ .concat(error?.response?.data?.message ?? __('Something went wrong. Please try again.'))
+ .join(', ');
+
const fetchUsersForRuleForProject = (
entityId,
entityType,
@@ -140,7 +147,13 @@ export const updateRule = ({ dispatch, state, commit }, { environment, ruleKey,
name: environment.name,
[ruleKey]: [Object.fromEntries(updatedRuleEntries)],
};
- return dispatch('updateEnvironment', updatedEnvironment).then(() => {
+ return dispatch('updateEnvironment', updatedEnvironment).then((updated) => {
+ // Committing here closes the edit form and discards what the user typed,
+ // so leave it open when `updateEnvironment` reports a rejected update.
+ // Only an explicit `false` means a rejection; the Vuex test helper's
+ // `dispatch` resolves `undefined`.
+ if (updated === false) return;
+
commit(types.RECEIVE_RULE_UPDATED, rule);
});
};
@@ -152,9 +165,16 @@ export const updateEnvironment = ({ state, commit, dispatch }, environment) => {
.then(({ data }) => {
commit(types.RECEIVE_UPDATE_PROTECTED_ENVIRONMENT_SUCCESS, data);
dispatch('fetchAllMembersForEnvironment', data);
+
+ return true;
})
.catch((error) => {
+ // The API rejects some updates, such as one that removes the last
+ // approval rule. Without the reason, the update looks like a no-op.
+ createAlert({ message: apiErrorMessage(error) });
commit(types.RECEIVE_UPDATE_PROTECTED_ENVIRONMENT_ERROR, error);
+
+ return false;
});
};
@@ -186,6 +206,7 @@ export const unprotectEnvironment = ({ state, commit, dispatch }, environment) =
}
})
.catch((error) => {
+ createAlert({ message: apiErrorMessage(error) });
commit(types.RECEIVE_UPDATE_PROTECTED_ENVIRONMENT_ERROR, error);
});
};
diff --git a/ee/spec/frontend/protected_environments/store/edit/actions_spec.js b/ee/spec/frontend/protected_environments/store/edit/actions_spec.js
index a931d1a72f02..13a1f2ee33fb 100644
--- a/ee/spec/frontend/protected_environments/store/edit/actions_spec.js
+++ b/ee/spec/frontend/protected_environments/store/edit/actions_spec.js
@@ -18,7 +18,13 @@ import {
import * as types from 'ee/protected_environments/store/edit/mutation_types';
import { state } from 'ee/protected_environments/store/edit/state';
import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
+import { createAlert } from '~/alert';
+import {
+ HTTP_STATUS_OK,
+ HTTP_STATUS_INTERNAL_SERVER_ERROR,
+ HTTP_STATUS_NOT_FOUND,
+ HTTP_STATUS_UNPROCESSABLE_ENTITY,
+} from '~/lib/utils/http_status';
import {
DEPLOYER_RULE_KEY,
RULE_KEYS,
@@ -27,6 +33,8 @@ import {
} from 'ee/protected_environments/constants';
import { MAINTAINER_ACCESS_LEVEL, DEVELOPER_ACCESS_LEVEL } from '../../constants';
+jest.mock('~/alert');
+
describe('ee/protected_environments/store/edit/actions', () => {
let mockedState;
let mock;
@@ -301,6 +309,20 @@ describe('ee/protected_environments/store/edit/actions', () => {
],
);
});
+
+ it('keeps the edit form open when the update was rejected', async () => {
+ const rule = { id: 1, group_id: 1 };
+ const commit = jest.fn();
+
+ mockedState.editingRules = { [rule.id]: rule };
+
+ await updateRule(
+ { commit, state: mockedState, dispatch: () => Promise.resolve(false) },
+ { environment, rule, ruleKey: DEPLOYER_RULE_KEY },
+ );
+
+ expect(commit).not.toHaveBeenCalled();
+ });
});
describe('updateEnvironment', () => {
@@ -329,10 +351,10 @@ describe('ee/protected_environments/store/edit/actions', () => {
);
});
- it('successfully retains the error', () => {
+ it('successfully retains the error', async () => {
mock.onPut(url, environment).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
- return testAction(
+ await testAction(
updateEnvironment,
environment,
mockedState,
@@ -344,6 +366,24 @@ describe('ee/protected_environments/store/edit/actions', () => {
],
[],
);
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Something went wrong. Please try again.',
+ });
+ });
+
+ it('alerts the reason the API rejected the update', async () => {
+ const reason = 'Approval rules cannot all be removed.';
+ mock
+ .onPut(url, environment)
+ .replyOnce(HTTP_STATUS_UNPROCESSABLE_ENTITY, { message: [reason] });
+
+ await updateEnvironment(
+ { state: mockedState, commit: jest.fn(), dispatch: jest.fn() },
+ environment,
+ );
+
+ expect(createAlert).toHaveBeenCalledWith({ message: reason });
});
});
@@ -470,13 +510,15 @@ describe('ee/protected_environments/store/edit/actions', () => {
);
});
- it('saves the error on failure', () => {
+ it('saves the error on failure and alerts the reason', async () => {
const environment = {
name: 'staging',
};
- mock.onDelete(url, environment).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ // This endpoint answers with a string message, not a model-error array.
+ const reason = '404 Not Found';
+ mock.onDelete(url, environment).replyOnce(HTTP_STATUS_NOT_FOUND, { message: reason });
- return testAction(
+ await testAction(
unprotectEnvironment,
environment,
mockedState,
@@ -486,6 +528,8 @@ describe('ee/protected_environments/store/edit/actions', () => {
],
[],
);
+
+ expect(createAlert).toHaveBeenCalledWith({ message: reason });
});
it('redirects to previous page after deleting if current page is empty', () => {Reference artifacts
These are closed and are context only - do not reopen them:
- https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/6675 - closed. Contained this frontend change alongside a model validation built on a false premise. The MR description explains why that premise was wrong.
- https://gitlab.com/gitlab-org/security/gitlab/-/work_items/1756 - closed, not a vulnerability.
- https://gitlab.com/gitlab-org/gitlab/-/issues/600088 - the real, AppSec-triaged issue. Scoped to approvers losing project access, which this is not.