From 41959a735ea518d13a7d3d08f9aa5bec2fb92f67 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:12:12 +0800 Subject: [PATCH] fix: save OIDC site access policies safely --- README.md | 13 +- SECURITY.md | 7 +- .../mfa-security-confirmation/component.js | 17 +- app/components/site-access/component.js | 128 ++++++++--- ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.118.md | 23 ++ package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- scripts/check-ui-test-harness-blockers | 4 +- .../mfa-security-confirmation-test.js | 39 ++++ tests/unit/components/site-access-test.js | 199 ++++++++++++++++++ translations/de-de.yaml | 7 + translations/en-us.yaml | 7 + translations/fa-ir.yaml | 7 + translations/fil-ph.yaml | 7 + translations/fr-fr.yaml | 7 + translations/hu-hu.yaml | 7 + translations/ja-jp.yaml | 7 + translations/ko-kr.yaml | 7 + translations/pt-br.yaml | 7 + translations/ru-ru.yaml | 7 + translations/uk-ua.yaml | 7 + translations/zh-hans.yaml | 7 + translations/zh-tw.yaml | 7 + 27 files changed, 498 insertions(+), 41 deletions(-) create mode 100644 docs/releases/web-console-1.6.118.md create mode 100644 tests/unit/components/site-access-test.js diff --git a/README.md b/README.md index b78df71178..d0b78aafcc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status -The current compatibility release is `1.6.117`. It retains the existing Node 24, Ember, Sass, +The current compatibility release is `1.6.118`. It retains the existing Node 24, Ember, Sass, dependency, browser-smoke, terminal, console, and test-harness modernization. It adds a provider-neutral OpenID Connect administration and sign-in flow with PKCE S256, staged configuration validation, a real test login before @@ -16,6 +16,17 @@ activation, and local-authentication recovery. Product-owned names, logos, icons, package metadata, and visible text use PastureStack branding. API models and protocol fields remain compatible. +Release `1.6.118` fixes OIDC site-access policy editing without weakening the +provider enablement boundary. Unrestricted mode clears stale authorized +identities before saving; restricted and required entries are normalized and +deduplicated by OIDC principal type and immutable external ID. Access expansion +opens the existing MFA security-confirmation dialog with a purpose and canonical +request digest supplied by the authentication service, retries exactly once, +and never retains the one-time ticket after completion or failure. Stable backend +codes are rendered as localized, actionable errors without exposing raw response +bodies. Pair this release with Authentication Service `v0.4.37` and Engine +`0.183.303` or newer. + Release `1.6.117` prevents an older same-origin browser tab from revoking or clearing a session that a newer tab has just established. Explicit user logout is now the only browser path that requests server-side token revocation. Passive diff --git a/SECURITY.md b/SECURITY.md index 262cb6748b..027b1b6542 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported state -The maintained compatibility release is the pure numeric `1.6.117` line used +The maintained compatibility release is the pure numeric `1.6.118` line used by the current PastureStack Server release. Earlier branded coordinates are historical records and are not current release or deployment targets. Authentication-provider combinations must still be validated by an @@ -37,6 +37,11 @@ administrator before activation. authentication material for that account. - Authenticator-enrollment QR codes are generated locally in the browser; a provisioning secret must never be sent to an external rendering service. +- OIDC site-access expansion uses a one-time MFA confirmation bound to the + authenticated operator, fixed operation purpose, and canonical request + digest. The browser retries the save at most once and clears the confirmation + ticket on success, cancellation, and failure. Unrestricted mode must submit an + explicit empty authorized-identity list. - WebAuthn requires user verification, the exact origin, a matching relying-party ID, and a non-public-suffix relying-party domain. HTTP is accepted only for a loopback test origin. diff --git a/app/components/mfa-security-confirmation/component.js b/app/components/mfa-security-confirmation/component.js index c05bb691d5..b931e34ada 100644 --- a/app/components/mfa-security-confirmation/component.js +++ b/app/components/mfa-security-confirmation/component.js @@ -50,9 +50,9 @@ export default ModalBase.extend({ }, begin() { - return this.request({ + return this.request(Object.assign({ operation: 'beginSecurityConfirmation', - }).then((challenge) => { + }, this.confirmationBinding())).then((challenge) => { this.setProperties({ challenge: challenge, method: null, @@ -70,17 +70,26 @@ export default ModalBase.extend({ }).then((xhr) => xhr.body); }, + confirmationBinding() { + let purpose = this.get('opts.purpose'); + let requestDigest = this.get('opts.requestDigest'); + if ( purpose && requestDigest ) { + return {purpose, requestDigest}; + } + return {}; + }, + finish(webAuthnResponse) { let challenge = this.get('challenge'); this.setProperties({waiting: true, errorMessage: null}); - return this.request({ + return this.request(Object.assign({ operation: 'confirmSecurityConfirmation', challengeId: challenge.challengeId, method: this.get('method'), verificationCode: this.get('verificationCode'), recoveryCode: this.get('recoveryCode'), webAuthnResponse: webAuthnResponse, - }).then((result) => { + }, this.confirmationBinding())).then((result) => { let onComplete = this.get('opts.onComplete'); if ( typeof onComplete === 'function' ) { onComplete(result.securityConfirmation); diff --git a/app/components/site-access/component.js b/app/components/site-access/component.js index 9c76d53303..6fdd1b70e7 100644 --- a/app/components/site-access/component.js +++ b/app/components/site-access/component.js @@ -1,12 +1,43 @@ import { service } from '@ember/service'; import Component from '@ember/component'; +import { get } from '@ember/object'; +import { Promise, reject } from 'rsvp'; import Errors from 'ui/utils/errors'; +const OIDC_ACCESS_POLICY_PURPOSE = 'oidcAccessPolicyUpdate'; +const CONFIG_ERROR_TRANSLATIONS = { + LocalRecoveryRequired: 'siteAccess.errors.localRecoveryRequired', + MfaConfirmationRequired: 'siteAccess.errors.mfaConfirmationRequired', + MfaConfirmationUnavailable: 'siteAccess.errors.mfaConfirmationUnavailable', + InvalidAccessMode: 'siteAccess.errors.invalidAccessMode', + InvalidAllowedIdentity: 'siteAccess.errors.invalidAllowedIdentity', +}; + +export function configUpdateErrorBody(err) { + let body = err && (err.body || err.responseJSON || + (err.xhr && err.xhr.responseJSON)); + if ( typeof body === 'string' ) { + try { + body = JSON.parse(body); + } catch (e) { + body = null; + } + } + return body || {}; +} + +export function configUpdateErrorCode(err) { + let body = configUpdateErrorBody(err); + return (err && err.code) || body.code || body.type || (err && err.type); +} + export default Component.extend({ tagName: 'section', classNames: ['well'], settings: service(), access: service(), + intl: service(), + modalService: service('modal'), model: null, individuals: 'siteAccess.users', @@ -19,11 +50,47 @@ export default Component.extend({ return this.get('copy.accessMode') !== 'unrestricted'; }.property('copy.accessMode'), + saveConfiguration(btnCb) { + this.send('clearError'); + + if ( this.get('showList') && !this.get('copy.allowedIdentities.length') ) + { + this.send('gotError', this.get('intl').t('siteAccess.errors.authorizedIdentityRequired')); + btnCb(); + return Promise.resolve(); + } + + this.set('saved', false); + + let copy = this.get('copy'); + if ( copy.get('accessMode') === 'unrestricted' ) { + copy.set('allowedIdentities', []); + } + return this.saveWithBoundConfirmation(copy, true).then(() => { + this.get('model').replaceWith(copy); + this.set('copy.allowedIdentities', this.get('copy.allowedIdentities').slice()); + this.set('saved', true); + }).catch((err) => { + if ( !err || !err.mfaConfirmationCancelled ) { + this.send('gotError', err); + } + }).finally(() => { + btnCb(); + }); + }, + actions: { addAuthorized: function(data) { this.send('clearError'); this.set('saved', false); - this.get('copy.allowedIdentities').pushObject(data); + let identities = this.get('copy.allowedIdentities'); + let duplicate = identities.find((identity) => { + return get(identity, 'externalIdType') === get(data, 'externalIdType') && + get(identity, 'externalId') === get(data, 'externalId'); + }); + if ( !duplicate ) { + identities.pushObject(data); + } }, removeIdentity: function(ident) { @@ -32,31 +99,12 @@ export default Component.extend({ }, save: function(btnCb) { - this.send('clearError'); - - if ( this.get('showList') && !this.get('copy.allowedIdentities.length') ) - { - this.send('gotError', 'You must add at least one authorized entry'); - btnCb(); - return; - } - - this.set('saved', false); - - let copy = this.get('copy'); - copy.save().then(() => { - this.get('model').replaceWith(copy); - this.set('copy.allowedIdentities', this.get('copy.allowedIdentities').slice()); - this.set('saved', true); - }).catch((err) => { - this.send('gotError', err); - }).finally(() => { - btnCb(); - }); + return this.saveConfiguration(btnCb); }, gotError: function(err) { - this.set('errors', [Errors.stringify(err)]); + let translation = CONFIG_ERROR_TRANSLATIONS[configUpdateErrorCode(err)]; + this.set('errors', [translation ? this.get('intl').t(translation) : Errors.stringify(err)]); }, clearError: function() { @@ -64,6 +112,34 @@ export default Component.extend({ }, }, + saveWithBoundConfirmation(copy, mayConfirm) { + return copy.save().catch((err) => { + let body = configUpdateErrorBody(err); + let code = configUpdateErrorCode(err); + let digest = body.requestDigest; + if ( !mayConfirm || code !== 'MfaConfirmationRequired' || + !/^[0-9a-f]{64}$/.test(digest || '') ) { + return reject(err); + } + + return new Promise((resolve, rejectSave) => { + this.get('modalService').toggleModal('mfa-security-confirmation', { + closeWithOutsideClick: false, + escToClose: false, + purpose: OIDC_ACCESS_POLICY_PURPOSE, + requestDigest: digest, + onComplete: (confirmation) => { + copy.set('securityConfirmation', confirmation); + this.saveWithBoundConfirmation(copy, false) + .finally(() => copy.set('securityConfirmation', null)) + .then(resolve, rejectSave); + }, + onCancel: () => rejectSave({mfaConfirmationCancelled: true}), + }); + }); + }); + }, + didReceiveAttrs() { this.set('copy', this.get('model').clone()); this.set('copy.allowedIdentities', (this.get('copy.allowedIdentities')||[]).slice()); @@ -78,7 +154,11 @@ export default Component.extend({ this.set('copy.allowedIdentities', identities); } - if ( this.get('copy.accessMode') !== 'unrestricted' ) + if ( this.get('copy.accessMode') === 'unrestricted' ) + { + this.set('copy.allowedIdentities', []); + } + else { let me = this.get('access.identity'); let found = identities.filterBy('id', me.get('id')).length > 0; diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 3999357b15..0f89cd0607 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.117", + "version": "1.6.118", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.117", + "version": "1.6.118", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.118.md b/docs/releases/web-console-1.6.118.md new file mode 100644 index 0000000000..2f5121e389 --- /dev/null +++ b/docs/releases/web-console-1.6.118.md @@ -0,0 +1,23 @@ +# Web Console 1.6.118 + +- Clear `allowedIdentities` whenever OIDC site access switches to unrestricted, + preventing a stale allowlist from surviving a permission expansion. +- Normalize and deduplicate restricted or required OIDC users and groups by + principal type and immutable external ID before submission. +- Handle stable Authentication Service policy errors without exposing raw + response content. Local recovery, MFA confirmation, and invalid identity + policies now produce distinct localized guidance. +- Bind an access-expansion confirmation to the backend-supplied operation + purpose and canonical request digest, then retry the save exactly once. + Malformed challenges are rejected before opening the dialog, and the one-time + ticket is cleared after success, cancellation, or failure. +- Preserve the complete `1.6.117` cross-tab session-ownership fix, including + explicit-only token revocation, deterministic session-generation adoption, + stale callback protection, refresh recovery, and JWT confinement to cookie + and memory. +- Add focused browser-unit coverage for unrestricted clearing, principal + normalization and deduplication, stable errors, malformed challenges, and + bounded MFA retry behavior. + +Use this release with Authentication Service `v0.4.37` and Orchestration Engine +`0.183.303` or newer for the complete OIDC policy-update boundary. diff --git a/package-lock.json b/package-lock.json index 3999357b15..0f89cd0607 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.117", + "version": "1.6.118", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.117", + "version": "1.6.118", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 0a98c58406..2d4f7e2482 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.117", + "version": "1.6.118", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index fc72b855f1..bc7cd71c44 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.117" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.117" +if [[ "$version" != "1.6.118" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.118" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 36708690a3..e9b615f6a9 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.117 browser-session broker-broadcast + 1.6.118 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 0699e37b60..bb35f543ea 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.117": +if package.get("version") != "1.6.118": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/scripts/check-ui-test-harness-blockers b/scripts/check-ui-test-harness-blockers index d81c2658f4..049511d552 100755 --- a/scripts/check-ui-test-harness-blockers +++ b/scripts/check-ui-test-harness-blockers @@ -51,8 +51,8 @@ if module_for_count != 0: fail("MODULE_FOR_USAGE_COUNT_UNEXPECTED", actual=module_for_count, expected=0) if module_for_component_count != 0: fail("MODULE_FOR_COMPONENT_USAGE_UNEXPECTED", actual=module_for_component_count, expected=0) -if direct_qunit_import_count != 115: - fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=115) +if direct_qunit_import_count != 116: + fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=116) volatile_computed_count = 0 for path in Path("app").rglob("*.js"): diff --git a/tests/unit/components/mfa-security-confirmation-test.js b/tests/unit/components/mfa-security-confirmation-test.js index aa459479c7..8201daabe0 100644 --- a/tests/unit/components/mfa-security-confirmation-test.js +++ b/tests/unit/components/mfa-security-confirmation-test.js @@ -35,3 +35,42 @@ test('does not start a passkey confirmation on an insecure connection', function 'the modal explains why the registered passkey is not shown'); }).finally(() => destroyOwned(component)); }); + +test('binds begin and confirm to the same purpose and request digest', async function(assert) { + let requests = []; + let digest = 'a'.repeat(64); + let completed; + let component = createOwned(MfaSecurityConfirmation, { + renderer: inertRenderer(), + intl: EmberObject.create(), + modalService: EmberObject.create({ + modalOpts: { + purpose: 'oidcAccessPolicyUpdate', + requestDigest: digest, + onComplete(value) { completed = value; }, + }, + toggleModal() {}, + }), + userStore: EmberObject.create({ + rawRequest(options) { + requests.push(options.data); + if ( options.data.operation === 'beginSecurityConfirmation' ) { + return resolve({body: {challengeId: 'challenge-1', methods: ['totp']}}); + } + return resolve({body: {securityConfirmation: 'bound-ticket'}}); + }, + }), + }, 'component'); + + await component.begin(); + component.setProperties({method: 'totp', verificationCode: '123456'}); + await component.finish(null); + + assert.strictEqual(requests.length, 2); + requests.forEach((request) => { + assert.strictEqual(request.purpose, 'oidcAccessPolicyUpdate'); + assert.strictEqual(request.requestDigest, digest); + }); + assert.strictEqual(completed, 'bound-ticket'); + destroyOwned(component); +}); diff --git a/tests/unit/components/site-access-test.js b/tests/unit/components/site-access-test.js new file mode 100644 index 0000000000..e1455a310e --- /dev/null +++ b/tests/unit/components/site-access-test.js @@ -0,0 +1,199 @@ +import { A } from '@ember/array'; +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { module, test } from 'qunit'; +import SiteAccess, { + configUpdateErrorBody, + configUpdateErrorCode, +} from 'ui/components/site-access/component'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Component | site access'); + +function identity(type, id) { + return EmberObject.create({id: `${type}:${id}`, externalIdType: type, externalId: id}); +} + +function createComponent(copy, extra = {}) { + let component; + run(() => { + component = createOwned(SiteAccess, Object.assign({ + renderer: inertRenderer(), + settings: EmberObject.create(), + access: EmberObject.create({identity: identity('oidc_user', 'admin')}), + intl: EmberObject.create({t(key) { return key; }}), + modalService: EmberObject.create({toggleModal() {}}), + model: EmberObject.create({ + clone() { return copy; }, + replaceWith() {}, + }), + }, extra), 'component'); + component.set('copy', copy); + }); + return component; +} + +test('switching to unrestricted clears the local allowlist immediately', function(assert) { + let copy = EmberObject.create({ + accessMode: 'restricted', + allowedIdentities: A([identity('oidc_user', 'alice')]), + }); + let component = createComponent(copy); + + run(() => copy.set('accessMode', 'unrestricted')); + component.accessModeChanged(); + + assert.deepEqual(copy.get('allowedIdentities'), [], + 'stale restricted identities cannot be submitted under unrestricted access'); + destroyOwned(component); +}); + +test('deduplicates the same OIDC principal in the editor', function(assert) { + let alice = identity('oidc_user', 'alice'); + let copy = EmberObject.create({accessMode: 'restricted', allowedIdentities: A([alice])}); + let component = createComponent(copy); + + let before = copy.get('allowedIdentities').filter((candidate) => { + return candidate.get('externalIdType') === 'oidc_user' && candidate.get('externalId') === 'alice'; + }).length; + + component.send('addAuthorized', identity('oidc_user', 'alice')); + + let after = copy.get('allowedIdentities').filter((candidate) => { + return candidate.get('externalIdType') === 'oidc_user' && candidate.get('externalId') === 'alice'; + }).length; + assert.strictEqual(before, 1); + assert.strictEqual(after, 1); + destroyOwned(component); +}); + +test('retries an access expansion only with the bound MFA confirmation', async function(assert) { + let saveCount = 0; + let digest = 'b'.repeat(64); + let submitted = []; + let modalOptions; + let copy = EmberObject.create({ + accessMode: 'unrestricted', + allowedIdentities: A([identity('oidc_user', 'stale')]), + save() { + saveCount++; + submitted.push({ + allowedIdentities: this.get('allowedIdentities').slice(), + securityConfirmation: this.get('securityConfirmation'), + }); + if ( saveCount === 1 ) { + return Promise.reject({body: { + code: 'MfaConfirmationRequired', + requestDigest: digest, + }}); + } + return Promise.resolve(this); + }, + }); + let component = createComponent(copy, { + modalService: EmberObject.create({ + toggleModal(name, options) { + assert.strictEqual(name, 'mfa-security-confirmation'); + modalOptions = options; + }, + }), + }); + + let savePromise = component.saveConfiguration(() => {}); + await Promise.resolve(); + assert.strictEqual(modalOptions.purpose, 'oidcAccessPolicyUpdate'); + assert.strictEqual(modalOptions.requestDigest, digest); + modalOptions.onComplete('one-time-ticket'); + await savePromise; + + assert.deepEqual(submitted[0].allowedIdentities, [], 'unrestricted first request clears stale identities'); + assert.strictEqual(submitted[0].securityConfirmation, undefined); + assert.strictEqual(submitted[1].securityConfirmation, 'one-time-ticket'); + assert.strictEqual(copy.get('securityConfirmation'), null, 'ticket is removed from the model after use'); + destroyOwned(component); +}); + +test('extracts stable errors from direct and xhr response shapes', function(assert) { + assert.deepEqual(configUpdateErrorBody({body: '{"code":"LocalRecoveryRequired"}'}), + {code: 'LocalRecoveryRequired'}); + assert.deepEqual(configUpdateErrorBody({xhr: {responseJSON: {code: 'MfaConfirmationRequired'}}}), + {code: 'MfaConfirmationRequired'}); + assert.strictEqual(configUpdateErrorCode({ + type: 'error', + body: {code: 'InvalidAllowedIdentity'}, + }), 'InvalidAllowedIdentity', 'a structured backend code wins over a generic transport type'); +}); + +test('rejects a malformed MFA digest without opening the modal or retrying', async function(assert) { + let saveCount = 0; + let modalCount = 0; + let copy = EmberObject.create({ + save() { + saveCount++; + return Promise.reject({body: { + code: 'MfaConfirmationRequired', + requestDigest: 'not-a-sha256-digest', + }}); + }, + }); + let component = createComponent(copy, { + modalService: EmberObject.create({toggleModal() { modalCount++; }}), + }); + + await component.saveWithBoundConfirmation(copy, true).then( + () => assert.ok(false, 'malformed challenge must reject'), + () => assert.ok(true, 'malformed challenge was rejected') + ); + + assert.strictEqual(saveCount, 1, 'the request was not retried'); + assert.strictEqual(modalCount, 0, 'the MFA modal was not opened'); + destroyOwned(component); +}); + +test('uses one MFA retry and clears the ticket when the retry fails', async function(assert) { + let saveCount = 0; + let modalCount = 0; + let modalOptions; + let digest = 'c'.repeat(64); + let copy = EmberObject.create({ + save() { + saveCount++; + return Promise.reject({body: { + code: 'MfaConfirmationRequired', + requestDigest: digest, + }}); + }, + }); + let component = createComponent(copy, { + modalService: EmberObject.create({ + toggleModal(name, options) { + modalCount++; + modalOptions = options; + }, + }), + }); + + let savePromise = component.saveWithBoundConfirmation(copy, true); + await Promise.resolve(); + modalOptions.onComplete('one-time-ticket'); + await savePromise.then( + () => assert.ok(false, 'failed retry must reject'), + () => assert.ok(true, 'failed retry was returned to the caller') + ); + + assert.strictEqual(saveCount, 2, 'only the initial request and one retry were sent'); + assert.strictEqual(modalCount, 1, 'only one MFA modal was opened'); + assert.strictEqual(copy.get('securityConfirmation'), null, 'ticket was cleared after failure'); + destroyOwned(component); +}); + +test('shows a localized stable error instead of a generic HTTP 400', function(assert) { + let copy = EmberObject.create({accessMode: 'restricted', allowedIdentities: A([])}); + let component = createComponent(copy); + + component.send('gotError', {body: {code: 'LocalRecoveryRequired'}}); + + assert.deepEqual(component.get('errors'), ['siteAccess.errors.localRecoveryRequired']); + destroyOwned(component); +}); diff --git a/translations/de-de.yaml b/translations/de-de.yaml index fc7a633767..f278634ec8 100644 --- a/translations/de-de.yaml +++ b/translations/de-de.yaml @@ -3675,6 +3675,13 @@ siteAccess: required: 'Schränke den Zugriff auf Authorisierte {individuals} und {collection}' listHeader: 'Authorisierte {individuals} und {collection}' noIdentity: Kine + errors: + authorizedIdentityRequired: Fügen Sie mindestens einen autorisierten Eintrag hinzu. + localRecoveryRequired: Bestätigen Sie innerhalb von fünf Minuten ein aktives lokales Systemadministratorkonto, bevor Sie die OpenID-Connect-Identitätsquelle ändern. + mfaConfirmationRequired: Bestätigen Sie diese Änderung der Zugriffsrichtlinie mit Multi-Faktor-Authentifizierung. + mfaConfirmationUnavailable: Die Multi-Faktor-Bestätigung ist vorübergehend nicht verfügbar. Versuchen Sie es später erneut. + invalidAccessMode: Wählen Sie einen gültigen Website-Zugriffsmodus aus. + invalidAllowedIdentity: Nur gültige OpenID-Connect-Benutzer und -Gruppen können autorisiert werden. users: Benutzer groups: Gruppen organizations: Organizationen diff --git a/translations/en-us.yaml b/translations/en-us.yaml index c6450dc1fe..d7b0c8a14d 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -3971,6 +3971,13 @@ siteAccess: required: "Restrict access to only Authorized {individuals} and {collection}" listHeader: "Authorized {individuals} and {collection}" noIdentity: None + errors: + authorizedIdentityRequired: Add at least one authorized entry. + localRecoveryRequired: Verify an active local system-administrator account within five minutes before changing the OpenID Connect identity source. + mfaConfirmationRequired: Confirm this access-policy change with multi-factor authentication. + mfaConfirmationUnavailable: Multi-factor confirmation is temporarily unavailable. Try again later. + invalidAccessMode: Select a valid site access mode. + invalidAllowedIdentity: Only valid OpenID Connect users and groups can be authorized. users: Users groups: Groups organizations: Organizations diff --git a/translations/fa-ir.yaml b/translations/fa-ir.yaml index ae05de3249..fc5d742b66 100644 --- a/translations/fa-ir.yaml +++ b/translations/fa-ir.yaml @@ -3572,6 +3572,13 @@ siteAccess: required: 'محدود کردن دسترسی فقط به مجاز {individuals} و {collection}' listHeader: 'مجاز {individuals} و {collection}' noIdentity: هیچ یک + errors: + authorizedIdentityRequired: حداقل یک مورد مجاز اضافه کنید. + localRecoveryRequired: پیش از تغییر منبع هویت OpenID Connect، یک حساب فعال مدیر محلی سیستم را در بازه پنج دقیقه‌ای تأیید کنید. + mfaConfirmationRequired: این تغییر خط‌مشی دسترسی را با احراز هویت چندمرحله‌ای تأیید کنید. + mfaConfirmationUnavailable: تأیید چندمرحله‌ای موقتاً در دسترس نیست. بعداً دوباره تلاش کنید. + invalidAccessMode: یک حالت معتبر برای دسترسی به سایت انتخاب کنید. + invalidAllowedIdentity: فقط کاربران و گروه‌های معتبر OpenID Connect را می‌توان مجاز کرد. users: کاربران groups: گروه ها organizations: سازمان ها diff --git a/translations/fil-ph.yaml b/translations/fil-ph.yaml index df4f28b261..287ff0ac28 100644 --- a/translations/fil-ph.yaml +++ b/translations/fil-ph.yaml @@ -3627,6 +3627,13 @@ siteAccess: required: 'Limitahan ang pag-access sa Awtorisadong {individuals} at {collection} lamang' listHeader: 'Awtorisadong {individuals} at {collection}' noIdentity: wala + errors: + authorizedIdentityRequired: Magdagdag ng kahit isang awtorisadong entry. + localRecoveryRequired: Patunayan ang isang aktibong lokal na system administrator account sa loob ng limang minuto bago baguhin ang OpenID Connect identity source. + mfaConfirmationRequired: Kumpirmahin ang pagbabagong ito sa patakaran sa access gamit ang multi-factor authentication. + mfaConfirmationUnavailable: Pansamantalang hindi magagamit ang multi-factor confirmation. Subukan muli mamaya. + invalidAccessMode: Pumili ng wastong site access mode. + invalidAllowedIdentity: Mga wastong OpenID Connect user at grupo lamang ang maaaring pahintulutan. users: Mga gumagamit groups: Mga grupo organizations: Mga organisasyon diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 612178b089..d8964a65e8 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -3624,6 +3624,13 @@ siteAccess: required: 'Restreindre l’accès aux seuls {individuals} autorisés et {collection}' listHeader: '{individuals} autorisés et {collection}' noIdentity: Aucun + errors: + authorizedIdentityRequired: Ajoutez au moins une entrée autorisée. + localRecoveryRequired: Vérifiez un compte d’administrateur système local actif dans les cinq minutes précédant la modification de la source d’identité OpenID Connect. + mfaConfirmationRequired: Confirmez cette modification de la stratégie d’accès avec l’authentification multifacteur. + mfaConfirmationUnavailable: La confirmation multifacteur est temporairement indisponible. Réessayez plus tard. + invalidAccessMode: Sélectionnez un mode d’accès au site valide. + invalidAllowedIdentity: Seuls les utilisateurs et groupes OpenID Connect valides peuvent être autorisés. users: Utilisateurs groups: Groupes organizations: Organisations diff --git a/translations/hu-hu.yaml b/translations/hu-hu.yaml index 6e8c93f359..ccb87d5cef 100644 --- a/translations/hu-hu.yaml +++ b/translations/hu-hu.yaml @@ -3643,6 +3643,13 @@ siteAccess: required: 'A hozzáférés korlátozása csak az engedélyezett {individuals} és {collection} számára' listHeader: 'Engedélyezett {individuals} és {collection}' noIdentity: Semmi + errors: + authorizedIdentityRequired: Adjon hozzá legalább egy engedélyezett bejegyzést. + localRecoveryRequired: Az OpenID Connect-identitásforrás módosítása előtt öt percen belül igazoljon egy aktív helyi rendszergazdai fiókot. + mfaConfirmationRequired: Erősítse meg ezt a hozzáférésiszabályzat-módosítást többtényezős hitelesítéssel. + mfaConfirmationUnavailable: A többtényezős megerősítés átmenetileg nem érhető el. Próbálja újra később. + invalidAccessMode: Válasszon érvényes webhely-hozzáférési módot. + invalidAllowedIdentity: Csak érvényes OpenID Connect-felhasználók és -csoportok engedélyezhetők. users: Felhasználók groups: Csoportok organizations: Szervezetek diff --git a/translations/ja-jp.yaml b/translations/ja-jp.yaml index 192c762e2f..703fe27e04 100644 --- a/translations/ja-jp.yaml +++ b/translations/ja-jp.yaml @@ -3569,6 +3569,13 @@ siteAccess: required: '認証済み {individuals} と {collection} のみに制限' listHeader: '認証済み {individuals} と {collection}' noIdentity: なし + errors: + authorizedIdentityRequired: 承認済みの項目を1件以上追加してください。 + localRecoveryRequired: OpenID Connect の ID ソースを変更する前に、5 分以内に有効なローカルシステム管理者アカウントを確認してください。 + mfaConfirmationRequired: このアクセス ポリシーの変更を多要素認証で確認してください。 + mfaConfirmationUnavailable: 多要素認証を一時的に利用できません。後でもう一度お試しください。 + invalidAccessMode: 有効なサイト アクセス モードを選択してください。 + invalidAllowedIdentity: 有効な OpenID Connect ユーザーとグループのみ承認できます。 users: ユーザー groups: グループ organizations: 組織 diff --git a/translations/ko-kr.yaml b/translations/ko-kr.yaml index 519f611828..da6346b940 100644 --- a/translations/ko-kr.yaml +++ b/translations/ko-kr.yaml @@ -3487,6 +3487,13 @@ siteAccess: required: '승인된 {individuals} 및 {collection}에만 액세스를 제한합니다.' listHeader: '승인된 {individuals} 및 {collection}' noIdentity: 없음 + errors: + authorizedIdentityRequired: 승인된 항목을 하나 이상 추가하세요. + localRecoveryRequired: OpenID Connect ID 소스를 변경하기 전에 5분 이내에 활성 로컬 시스템 관리자 계정을 확인하세요. + mfaConfirmationRequired: 다단계 인증으로 이 액세스 정책 변경을 확인하세요. + mfaConfirmationUnavailable: 다단계 인증을 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요. + invalidAccessMode: 올바른 사이트 액세스 모드를 선택하세요. + invalidAllowedIdentity: 올바른 OpenID Connect 사용자와 그룹만 승인할 수 있습니다. users: 사용자 groups: 그룹 organizations: 조직 diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index 34c11b9689..0a79291d82 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -3626,6 +3626,13 @@ siteAccess: required: 'Restringir o acesso apenas a autorizados {individuals} e {collection}' listHeader: 'Autorizado {individuals} e {collection}' noIdentity: Nenhum + errors: + authorizedIdentityRequired: Adicione pelo menos uma entrada autorizada. + localRecoveryRequired: Verifique uma conta ativa de administrador local do sistema nos cinco minutos anteriores à alteração da origem de identidade OpenID Connect. + mfaConfirmationRequired: Confirme esta alteração da política de acesso com autenticação multifator. + mfaConfirmationUnavailable: A confirmação multifator está temporariamente indisponível. Tente novamente mais tarde. + invalidAccessMode: Selecione um modo de acesso ao site válido. + invalidAllowedIdentity: Somente usuários e grupos OpenID Connect válidos podem ser autorizados. users: Usuários groups: Grupos organizations: Organizações diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index f1cb149cc7..1fcb198392 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -3653,6 +3653,13 @@ siteAccess: required: 'Ограничить доступ только авторизованным {individuals} и {collection}' listHeader: 'Авторизованные {individuals} и {collection}' noIdentity: Отсутсвует + errors: + authorizedIdentityRequired: Добавьте хотя бы одну разрешённую запись. + localRecoveryRequired: Подтвердите активную локальную учетную запись системного администратора в течение пяти минут перед изменением источника идентификации OpenID Connect. + mfaConfirmationRequired: Подтвердите изменение политики доступа с помощью многофакторной аутентификации. + mfaConfirmationUnavailable: Многофакторное подтверждение временно недоступно. Повторите попытку позже. + invalidAccessMode: Выберите допустимый режим доступа к сайту. + invalidAllowedIdentity: Можно разрешать только допустимых пользователей и группы OpenID Connect. users: Пользователи groups: Группы organizations: Организации diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index f40a9a2138..78992b6044 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -3677,6 +3677,13 @@ siteAccess: required: 'Обмежити доступ лише до авторизованих {individuals} і {collection}' listHeader: 'Авторизовані {individuals} і {collection}' noIdentity: Немає + errors: + authorizedIdentityRequired: Додайте принаймні один дозволений запис. + localRecoveryRequired: Підтвердьте активний локальний обліковий запис системного адміністратора протягом п’яти хвилин перед зміною джерела ідентифікації OpenID Connect. + mfaConfirmationRequired: Підтвердьте цю зміну політики доступу за допомогою багатофакторної автентифікації. + mfaConfirmationUnavailable: Багатофакторне підтвердження тимчасово недоступне. Спробуйте пізніше. + invalidAccessMode: Виберіть припустимий режим доступу до сайту. + invalidAllowedIdentity: Можна дозволяти лише припустимих користувачів і групи OpenID Connect. users: Користувачі groups: Групи organizations: Організації diff --git a/translations/zh-hans.yaml b/translations/zh-hans.yaml index 5d69c057c9..31b29bc03a 100644 --- a/translations/zh-hans.yaml +++ b/translations/zh-hans.yaml @@ -3521,6 +3521,13 @@ siteAccess: required: '仅允许授权的 {individuals} 和 {collection}' listHeader: '授权的 {individuals} 和 {collection}' noIdentity: 无 + errors: + authorizedIdentityRequired: 请至少添加一个已授权项目。 + localRecoveryRequired: 请在五分钟内验证可用的本地系统管理员帐户,再更改 OpenID Connect 身份来源。 + mfaConfirmationRequired: 请使用多因素身份验证确认此次访问策略更改。 + mfaConfirmationUnavailable: 暂时无法进行多因素身份验证,请稍后重试。 + invalidAccessMode: 请选择有效的站点访问模式。 + invalidAllowedIdentity: 只能授权有效的 OpenID Connect 用户和组。 users: 用户 groups: 组 organizations: 组织机构 diff --git a/translations/zh-tw.yaml b/translations/zh-tw.yaml index 4f3e14720f..15dcdaed05 100644 --- a/translations/zh-tw.yaml +++ b/translations/zh-tw.yaml @@ -3792,6 +3792,13 @@ siteAccess: required: '僅允許授權的 {individuals} 和 {collection}' listHeader: '授權的 {individuals} 和 {collection}' noIdentity: 無 + errors: + authorizedIdentityRequired: 請至少新增一個已授權項目。 + localRecoveryRequired: 請在五分鐘內驗證可用的本機系統管理員帳號,再變更 OpenID Connect 身分來源。 + mfaConfirmationRequired: 請使用多因素驗證確認這次存取政策變更。 + mfaConfirmationUnavailable: 暫時無法進行多因素驗證,請稍後再試。 + invalidAccessMode: 請選擇有效的站點存取模式。 + invalidAllowedIdentity: 只能授權有效的 OpenID Connect 使用者與群組。 users: 使用者 groups: 群組 organizations: 組織