diff --git a/changelog/29416.txt b/changelog/29416.txt new file mode 100644 index 000000000000..d2a92e57190a --- /dev/null +++ b/changelog/29416.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui (enterprise): Fixes token renewal to ensure capability checks are performed in the relevant namespace, resolving 'Not authorized' errors for resources that users have permission to access. +``` \ No newline at end of file diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 8bc380ccd249..b84c8c404204 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -246,15 +246,14 @@ export default Service.extend({ // haven't set a value yet // all of the typeof checks are necessary because the root namespace is '' let userRootNamespace = namespace_path && namespace_path.replace(/\/$/, ''); - // if we're logging in with token and there's no namespace_path, we can assume + // renew-self does not return namespace_path, so we manually setting in renew(). + // so if we're logging in with token and there's no namespace_path, we can assume // that the token belongs to the root namespace if (backend === 'token' && !userRootNamespace) { userRootNamespace = ''; } - if (typeof userRootNamespace === 'undefined') { - if (this.authData) { - userRootNamespace = this.authData.userRootNamespace; - } + if (typeof userRootNamespace === 'undefined' && this.authData) { + userRootNamespace = this.authData.userRootNamespace; } if (typeof userRootNamespace === 'undefined') { userRootNamespace = currentNamespace; @@ -374,7 +373,13 @@ export default Service.extend({ return this.renewCurrentToken().then( (resp) => { this.isRenewing = false; - return this.persistAuthData(tokenName, resp.data || resp.auth); + const namespacePath = this.namespaceService.path; + const response = resp.data || resp.auth; + // renew-self does not return namespace_path, so manually add it if it exists + if (!response?.namespace_path && namespacePath) { + response.namespace_path = namespacePath; + } + return this.persistAuthData(tokenName, response); }, (e) => { this.isRenewing = false; diff --git a/ui/tests/acceptance/auth-test.js b/ui/tests/acceptance/auth-test.js index 4d9feb8c24d3..c992c32a4c57 100644 --- a/ui/tests/acceptance/auth-test.js +++ b/ui/tests/acceptance/auth-test.js @@ -9,12 +9,18 @@ import { click, currentURL, visit, waitUntil, find, fillIn } from '@ember/test-h import { setupMirage } from 'ember-cli-mirage/test-support'; import { allSupportedAuthBackends, supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; import VAULT_KEYS from 'vault/tests/helpers/vault-keys'; +import { + createNS, + createPolicyCmd, + mountAuthCmd, + mountEngineCmd, + runCmd, +} from 'vault/tests/helpers/commands'; +import { login, loginMethod, loginNs, logout } from 'vault/tests/helpers/auth/auth-helpers'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { v4 as uuidv4 } from 'uuid'; +import { GENERAL } from '../helpers/general-selectors'; -const AUTH_FORM = { - method: '[data-test-select=auth-method]', - token: '[data-test-token]', - login: '[data-test-auth-submit]', -}; const ENT_AUTH_METHODS = ['saml']; const { rootToken } = VAULT_KEYS; @@ -39,10 +45,10 @@ module('Acceptance | auth', function (hooks) { test('it clears token when changing selected auth method', async function (assert) { await visit('/vault/auth'); - await fillIn(AUTH_FORM.token, 'token'); + await fillIn(AUTH_FORM.input('token'), 'token'); await fillIn(AUTH_FORM.method, 'github'); await fillIn(AUTH_FORM.method, 'token'); - assert.dom(AUTH_FORM.token).hasNoValue('it clears the token value when toggling methods'); + assert.dom(AUTH_FORM.input('token')).hasNoValue('it clears the token value when toggling methods'); }); module('it sends the right payload when authenticating', function (hooks) { @@ -202,10 +208,85 @@ module('Acceptance | auth', function (hooks) { () => new Error('should not call renew-self directly after logging in') ); - await visit('/vault/auth'); - await fillIn(AUTH_FORM.method, 'token'); - await fillIn(AUTH_FORM.token, rootToken); - await click('[data-test-auth-submit]'); + await login(rootToken); assert.strictEqual(currentURL(), '/vault/dashboard'); }); + + module('Enterprise', function (hooks) { + hooks.beforeEach(async function () { + const uid = uuidv4(); + this.ns = `admin-${uid}`; + // log in to root to create namespace + await login(); + await runCmd(createNS(this.ns), false); + // login to namespace, mount userpass, create policy and user + await loginNs(this.ns); + this.db = `database-${uid}`; + this.userpass = `userpass-${uid}`; + this.user = 'bob'; + this.policyName = `policy-${this.userpass}`; + this.policy = ` + path "${this.db}/" { + capabilities = ["list"] + } + path "${this.db}/roles" { + capabilities = ["read","list"] + } + `; + await runCmd([ + mountAuthCmd('userpass', this.userpass), + mountEngineCmd('database', this.db), + createPolicyCmd(this.policyName, this.policy), + `write auth/${this.userpass}/users/${this.user} password=${this.user} token_policies=${this.policyName}`, + ]); + return await logout(); + }); + + hooks.afterEach(async function () { + await visit(`/vault/logout?namespace=${this.ns}`); + await fillIn(AUTH_FORM.namespaceInput, ''); // clear login form namespace input + await login(); + await runCmd([`delete sys/namespaces/${this.ns}`], false); + }); + + // this test is specifically to cover a token renewal bug within namespaces + // namespace_path isn't returned by the renew-self response and so the auth service was + // incorrectly setting userRootNamespace to '' (which denotes 'root') + // making subsequent capability checks fail because they would not be queried with the appropriate namespace header + // if this test fails because a POST /v1/sys/capabilities-self returns a 403, then we have a problem! + test('it sets namespace when renewing token', async function (assert) { + await login(); + await runCmd([ + mountAuthCmd('userpass', this.userpass), + mountEngineCmd('database', this.db), + createPolicyCmd(this.policyName, this.policy), + `write auth/${this.userpass}/users/${this.user} password=${this.user} token_policies=${this.policyName}`, + ]); + + const options = { username: this.user, password: this.user, 'auth-form-mount-path': this.userpass }; + + // login as user just to get token (this is the only way to generate a token in the UI right now..) + await loginMethod('userpass', options, { toggleOptions: true, ns: this.ns }); + await click('[data-test-user-menu-trigger=""]'); + const token = find('[data-test-copy-button]').getAttribute('data-test-copy-button'); + + // login with token to reproduce bug + await loginNs(this.ns, token); + await visit(`/vault/secrets/${this.db}/overview?namespace=${this.ns}`); + assert + .dom('[data-test-overview-card="Roles"]') + .hasText('Roles Create new', 'database overview renders'); + // renew token + await click('[data-test-user-menu-trigger=""]'); + await click('[data-test-user-menu-item="renew token"]'); + // navigate out and back to overview tab to re-request capabilities + await click(GENERAL.secretTab('Roles')); + await click(GENERAL.tab('overview')); + assert.strictEqual( + currentURL(), + `/vault/secrets/${this.db}/overview?namespace=${this.ns}`, + 'it navigates to database overview' + ); + }); + }); }); diff --git a/ui/tests/helpers/auth/auth-form-selectors.ts b/ui/tests/helpers/auth/auth-form-selectors.ts index b5a3730ea922..07cf84a1aa61 100644 --- a/ui/tests/helpers/auth/auth-form-selectors.ts +++ b/ui/tests/helpers/auth/auth-form-selectors.ts @@ -4,6 +4,7 @@ */ export const AUTH_FORM = { + method: '[data-test-select=auth-method]', form: '[data-test-auth-form]', login: '[data-test-auth-submit]', tabs: (method: string) => (method ? `[data-test-auth-method="${method}"]` : '[data-test-auth-method]'), diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts index 544bdcb2a063..3320e3849035 100644 --- a/ui/tests/helpers/auth/auth-helpers.ts +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -9,6 +9,7 @@ import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; const { rootToken } = VAULT_KEYS; +// LOGIN WITH TOKEN export const login = async (token = rootToken) => { // make sure we're always logged out and logged back in await logout(); @@ -26,6 +27,30 @@ export const loginNs = async (ns: string, token = rootToken) => { return click(AUTH_FORM.login); }; +// LOGIN WITH NON-TOKEN methods +/* +inputValues are for filling in the form values +the key completes to the input's test selector and fills it in with the corresponding value +for example: { username: 'bob', password: 'my-password', 'auth-form-mount-path': 'userpasss1' }; +*/ +export const loginMethod = async ( + methodType: string, + inputValues: object, + { toggleOptions = false, ns = '' } +) => { + // make sure we're always logged out and logged back in + await logout(); + await visit(`/vault/auth?with=${methodType}`); + + if (ns) await fillIn(AUTH_FORM.namespaceInput, ns); + if (toggleOptions) await click(AUTH_FORM.moreOptions); + + for (const [input, value] of Object.entries(inputValues)) { + await fillIn(AUTH_FORM.input(input), value); + } + return click(AUTH_FORM.login); +}; + export const logout = async () => { // make sure we're always logged out and logged back in await visit('/vault/logout');