diff --git a/README.md b/README.md index 6be73294c1..3f1535c337 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 source compatibility target is `1.6.152`. It retains the existing Node 24, Ember, Sass, +The current source compatibility target is `1.6.153`. 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,16 @@ 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.153` makes Stack, Service, and Container write controls check +their current project/resource capability when the user acts; delayed project +upgrades and service scaling cannot carry a click into a different selected +project. It improves Registry edit recovery, localized errors and Japanese +form labels, and shows an unavailable state when host/container monitoring +cannot connect instead of leaving a spinner. These are browser-console +changes, not a substitute for Server-side per-resource authorization. See +the [release note](docs/releases/web-console-1.6.153.md) for test evidence +and the remaining 8080 acceptance boundary. + Release `1.6.152` uses each Stack, Service, and Container form's visible, translated name label in its required-field error. This closes the Chinese/Japanese Stack mismatch observed on isolated Server v1.6.485 QA; diff --git a/app/catalog-tab/launch/route.js b/app/catalog-tab/launch/route.js index 58546c6186..d8dcd5f950 100644 --- a/app/catalog-tab/launch/route.js +++ b/app/catalog-tab/launch/route.js @@ -4,7 +4,7 @@ import Route from '@ember/routing/route'; import EmberObject, { get } from '@ember/object'; import C from 'ui/utils/constants'; import { catalogVersionOptions } from 'ui/utils/catalog-version-options'; -import RequireCreatePermission from 'ui/mixins/require-create-permission'; +import Errors from 'ui/utils/errors'; function resourceValue(resource, path) { if ( !resource ) { @@ -18,33 +18,63 @@ function resourceValue(resource, path) { return get(resource, path); } -export default Route.extend(RequireCreatePermission, { +export default Route.extend({ catalog: service(), - - requiredCreateType: 'stack', - requiredUpdateType: 'stack', - updateWhenQueryParam: 'upgrade', + intl: service(), + projects: service(), parentRoute: 'catalog-tab', + unavailable(key, status=403) { + return {status, code: status === 404 ? 'NotFound' : 'Forbidden', messageKey: key, message: this.get('intl').t(key)}; + }, + + fetchTemplate(id, upgrade=false) { + return this.get('catalog').fetchTemplate(id, upgrade).catch((err) => { + let status = Errors.status(err); + if ( status === 403 || status === 404 ) { + throw this.unavailable(upgrade ? 'newCatalog.upgradeUnavailable' : 'newCatalog.templateUnavailable', 404); + } + throw err; + }); + }, + model: function(params/*, transition*/) { var store = this.get('store'); + let projectId = this.get('projects.current.id'); + + if ( !params.stackId && !this.get('projects').canCreateResource('stack') ) { + throw this.unavailable('newCatalog.permissionDenied'); + } + if ( params.upgrade && !params.stackId ) { + throw this.unavailable('newCatalog.upgradeUnavailable'); + } var dependencies = { - tpl: this.get('catalog').fetchTemplate(params.template), + tpl: this.fetchTemplate(params.template), }; if ( params.upgrade ) { - dependencies.upgrade = this.get('catalog').fetchTemplate(params.upgrade, true); + dependencies.upgrade = this.fetchTemplate(params.upgrade, true); } if ( params.stackId ) { - dependencies.stack = store.find('stack', params.stackId); + dependencies.stack = store.find('stack', params.stackId).catch((err) => { + let status = Errors.status(err); + if ( status === 403 || status === 404 ) { + throw this.unavailable('resourceLoadError.stackUnavailable', 404); + } + throw err; + }); } return hash(dependencies, 'Load dependencies').then((results) => { + if ( results.stack && !resourceValue(results.stack, 'actionLinks.upgrade') ) { + throw this.unavailable('newCatalog.upgradeUnavailable'); + } + if ( !results.stack ) { results.stack = store.createRecord({ @@ -73,6 +103,7 @@ export default Route.extend(RequireCreatePermission, { let verArr = catalogVersionOptions(links, currentOption); return EmberObject.create({ + projectId, stack: results.stack, tpl: results.tpl, upgrade: results.upgrade, diff --git a/app/catalog-tab/launch/template.hbs b/app/catalog-tab/launch/template.hbs index 7edebdec32..0d0cb5ce8e 100644 --- a/app/catalog-tab/launch/template.hbs +++ b/app/catalog-tab/launch/template.hbs @@ -1,6 +1,7 @@ {{new-catalog allTemplates=this.model.allTemplates stackResource=this.model.stack + projectId=this.model.projectId templateResource=this.model.tpl versionLinks=this.model.versionLinks versionsArray=this.model.versionsArray diff --git a/app/components/edit-registry/component.js b/app/components/edit-registry/component.js index 1deed47d5b..358ff92116 100644 --- a/app/components/edit-registry/component.js +++ b/app/components/edit-registry/component.js @@ -15,10 +15,26 @@ export default ModalBase.extend(NewOrEdit, { editing: true, primaryResource: null, intl: service(), + projects: service(), missingCredential: false, credentialSaveAttempted: false, credentialOutcomeUnknown: false, + canSaveCredential: function() { + let projectId = this.get('originalModel.projectId'); + let currentProjectId = this.get('projects.current.id'); + if ( !currentProjectId || this.get('projects.schemaProjectId') !== currentProjectId || + projectId !== currentProjectId ) { + return false; + } + + let credential = this.get('originalModel.credential'); + return credential ? Boolean(credential.get('actionLinks.update')) : + this.get('projects').canCreateResource('registryCredential'); + }.property('originalModel.credential', 'originalModel.credential.actionLinks.update', + 'originalModel.projectId', 'projects.current.id', 'projects.schemaProjectId', + 'projects.schemaLoadGeneration'), + init: function() { this._super(...arguments); var orig = this.get('originalModel'); @@ -46,6 +62,10 @@ export default ModalBase.extend(NewOrEdit, { }, doSave: function() { + if ( !this.get('canSaveCredential') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + if ( !this.get('missingCredential') ) { const credential = this.get('primaryResource'); const data = { @@ -53,11 +73,26 @@ export default ModalBase.extend(NewOrEdit, { secretValue: credential.get('secretValue'), }; - return this._super({data}); + const registryId = this.get('originalModel.registry.id'); + const saveCredential = this._super.bind(this); + return this.get('originalModel.registry.store').find('registrycredential', credential.get('id'), {forceReload: true}).then((fresh) => { + if ( !fresh || fresh.get('registryId') !== registryId ) { + throw {status: 404, code: 'NotFound', messageKey: 'resourceSaveError.unavailable'}; + } + if ( !this.get('canSaveCredential') || !fresh.get('actionLinks.update') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + + return saveCredential({data}); + }); } const registry = this.get('originalModel.registry'); return registry.get('store').find('registrycredential', null, {forceReload: true}).then((credentials) => { + if ( !this.get('canSaveCredential') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + const existing = credentialsForRegistry(credentials, registry.get('id')); if ( existing.get('length') ) { throw new Error(this.get('intl').t('editRegistry.credentialAppeared')); @@ -66,8 +101,14 @@ export default ModalBase.extend(NewOrEdit, { throw new Error(this.get('intl').t('editRegistry.credentialOutcomeUnknown')); } - this.set('credentialSaveAttempted', true); - return resolve().then(() => this.get('primaryResource').save()).then( + return resolve().then(() => { + if ( !this.get('canSaveCredential') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + + this.set('credentialSaveAttempted', true); + return this.get('primaryResource').save(); + }).then( (saved) => this.mergeResult(saved), (error) => { this.set('credentialOutcomeUnknown', !definitelyRejected(error)); diff --git a/app/components/edit-registry/template.hbs b/app/components/edit-registry/template.hbs index 077428a548..aaa53c115c 100644 --- a/app/components/edit-registry/template.hbs +++ b/app/components/edit-registry/template.hbs @@ -35,4 +35,4 @@ -{{save-cancel editing=this.editing save="save" cancel="cancel"}} +{{save-cancel editing=this.editing save="save" cancel="cancel" saveDisabled=(not this.canSaveCredential)}} diff --git a/app/components/info-multi-stats/component.js b/app/components/info-multi-stats/component.js index 6f4478de24..ecb40f188f 100644 --- a/app/components/info-multi-stats/component.js +++ b/app/components/info-multi-stats/component.js @@ -1,5 +1,5 @@ import { cancel, next } from '@ember/runloop'; -import { alias, and, not } from '@ember/object/computed'; +import { alias, and, not, or } from '@ember/object/computed'; import { service } from '@ember/service'; import Component from '@ember/component'; import bb from 'billboard.js'; @@ -93,7 +93,18 @@ export default Component.extend({ active: alias('statsSocket.active'), loading: alias('statsSocket.loading'), notRenderOk: not('renderOk'), - waitingForData: and('available', 'notRenderOk'), + statsError: alias('statsSocket.connectError'), + statsErrorStatus: alias('statsSocket.connectErrorStatus'), + noStatsError: not('statsError'), + noStatsAvailable: not('available'), + showUnavailable: or('noStatsAvailable', 'statsError'), + waitingForData: and('available', 'notRenderOk', 'noStatsError'), + unavailableMessage: function() { + const status = this.get('statsErrorStatus'); + const key = status === 401 ? 'authError' : status === 403 ? 'permissionError' : + status === 404 ? 'notFound' : 'utilizationStats'; + return this.get('intl').t(`infoMultiStats.${key}`); + }.property('statsErrorStatus'), cpuCanvas: '#cpuGraph', cpuGraph: null, diff --git a/app/components/info-multi-stats/template.hbs b/app/components/info-multi-stats/template.hbs index 7f8ffe3e49..6e5249aad2 100644 --- a/app/components/info-multi-stats/template.hbs +++ b/app/components/info-multi-stats/template.hbs @@ -16,8 +16,8 @@