Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Fix token renewal breaking policy checks #29416

Merged
merged 3 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/29416.txt
Original file line number Diff line number Diff line change
@@ -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.
```
17 changes: 11 additions & 6 deletions ui/app/services/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
hellobontempo marked this conversation as resolved.
Show resolved Hide resolved
response.namespace_path = namespacePath;
}
return this.persistAuthData(tokenName, response);
},
(e) => {
this.isRenewing = false;
Expand Down
103 changes: 92 additions & 11 deletions ui/tests/acceptance/auth-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice clean up work on the tests!


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;

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Copy link
Contributor

@Monkeychip Monkeychip Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totally not required, but i've found it helpful when folks have mentioned that a test is written for a specific bug to include the github pr or issue link in the comment. Matthew has done this a couple times and I've used those links for more context or additional regression testing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to do this if it was a GitHub link! But since it's a jira link I didn't include it. I remember a while ago the company discussed that we shouldn't share internal links publicly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, yep that's the correct move then.

// 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'
);
});
});
});
1 change: 1 addition & 0 deletions ui/tests/helpers/auth/auth-form-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]'),
Expand Down
25 changes: 25 additions & 0 deletions ui/tests/helpers/auth/auth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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');
Expand Down
Loading