UI: Fix token renewal breaking policy checks (#29416)

* set namespace_path in renewal method

* add tests

* add changelog
This commit is contained in:
claire bontempo
2025-02-03 10:56:39 -08:00
committed by GitHub
parent 73a98ddd48
commit 5d91f7d9bc
5 changed files with 132 additions and 17 deletions

3
changelog/29416.txt Normal file
View File

@@ -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.
```

View File

@@ -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;

View File

@@ -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'
);
});
});
});

View File

@@ -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]'),

View File

@@ -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');