UI: Fix namespace context in capabilities service (#31276)

* test coverage for namespace capabilities checks;

* set namespace header with appropriate context

* add more test coverage, restore stub

* add changelog
This commit is contained in:
claire bontempo 2025-07-15 10:00:15 -07:00 committed by GitHub
parent 8f522a2bca
commit e29c6e3496
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 158 additions and 11 deletions

3
changelog/31276.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:bug
ui: Fix regression in 1.20.0 to properly set namespace context for capabilities checks
```

View file

@ -34,7 +34,7 @@ export default class CapabilitiesService extends Service {
Users don't always have access to the capabilities-self endpoint in the current namespace.
This can happen when logging in to a namespace and then navigating to a child namespace.
The "relativeNamespace" refers to the namespace the user is currently in and attempting to access capabilities for.
Prepending "relativeNamespace" to the path while making the request to the "userRootNamespace"
Prepending "relativeNamespace" to the path while making the request in the "userRootNamespace" context (meaning, "userRootNamespace" is the namespace header)
ensures we are querying capabilities-self where the user is most likely to have their policy/permissions.
*/
relativeNamespacePath(path: string) {
@ -83,16 +83,13 @@ export default class CapabilitiesService extends Service {
}
async fetch(paths: string[]): Promise<CapabilitiesMap> {
const payload = {
paths: paths.map((path) => this.relativeNamespacePath(path)),
namespace: sanitizePath(this.namespace.userRootNamespace),
};
if (!payload.namespace) {
delete payload.namespace;
}
const payload = { paths: paths.map((path) => this.relativeNamespacePath(path)) };
try {
const { data } = await this.api.sys.queryTokenSelfCapabilities(payload);
const { data } = await this.api.sys.queryTokenSelfCapabilities(
payload,
this.api.buildHeaders({ namespace: sanitizePath(this.namespace.userRootNamespace) })
);
return this.mapCapabilities(paths, data as CapabilitiesData);
} catch (e) {
// default to true if there is a problem fetching the model

View file

@ -254,9 +254,9 @@ module('Unit | Service | capabilities', function (hooks) {
});
});
module('within namespace', function (hooks) {
module('within a namespace', function (hooks) {
// capabilities within namespaces are queried at the user's root namespace with a path that includes
// the relative namespace. The capabilities record is saved at the path without the namespace.
// the relative namespace.
hooks.beforeEach(function () {
this.nsSvc = this.owner.lookup('service:namespace');
this.nsSvc.path = 'ns1';
@ -344,5 +344,152 @@ module('Unit | Service | capabilities', function (hooks) {
};
assert.propEqual(actual, expected, 'method returns expected response');
});
/*
The setup in this test simulates a user whose auth method is mounted in the "root" namespace
but their policy only grants access to paths in the context of the "ns1" namespace.
* ~Example policy paths~ *
# explicitly grants access to read "my-secret" in the kv engine mounted in the "ns1" namespace
path "ns1/kv/data/my-secret" {
capabilities = ["read", "delete"]
}
# alternatively, their policy could grant access to read everything within the "ns1" namespace
path "ns1/*" {
capabilities = ["read"]
}
*/
test(`if the user's root namespace is "root" and the resource is in a child namespace`, async function (assert) {
assert.expect(2);
const ns = this.nsSvc.path;
const paths = ['my/api/path', '/another/api/path'];
const expectedPayload = [`${ns}/my/api/path`, `${ns}/another/api/path`];
this.server.post('/sys/capabilities-self', (schema, req) => {
const nsHeader = req.requestHeaders['x-vault-namespace'];
const payload = JSON.parse(req.requestBody);
assert.strictEqual(nsHeader, '', 'request is made in the context of the "root" namespace');
assert.propEqual(payload.paths, expectedPayload, `paths include the relative namespace`);
return req.passthrough();
});
await this.capabilities.fetch(paths);
});
/*
The setup in this test simulates a user whose root namespace is "root" and
they are accessing a resource at a nested namespace: "ns1/child".
*/
test(`if the user's root namespace is "root" and the resource is in a grandchild`, async function (assert) {
assert.expect(2);
// the path in the namespace service is always the FULL namespace path of the current context
this.nsSvc.path = 'ns1/child';
const paths = ['my/api/path', '/another/api/path'];
const expectedPaths = ['ns1/child/my/api/path', 'ns1/child/another/api/path'];
this.server.post('/sys/capabilities-self', (schema, req) => {
const nsHeader = req.requestHeaders['x-vault-namespace'];
const payload = JSON.parse(req.requestBody);
assert.strictEqual(nsHeader, '', 'request is made in the context of the "root" namespace');
assert.propEqual(payload.paths, expectedPaths, `paths include the relative namespace`);
return req.passthrough();
});
await this.capabilities.fetch(paths);
});
/*
The setup in this test simulates a user whose auth method is mounted in the "ns1" namespace and so cannot log in directly to "root" at all.
Since this user's context (along with their policy) is exclusively "ns1" the paths do not include the namespace.
* ~Example policy paths~ *
path "kv/data/my-secret" {
capabilities = ["read", "delete"]
}
*/
test(`if the user's root namespace is an immediate child of "root" and they are accessing resources in the same namespace context`, async function (assert) {
assert.expect(2);
const ns = this.nsSvc.path;
const authService = this.owner.lookup('service:auth');
const authStub = sinon.stub(authService, 'authData').value({ userRootNamespace: ns });
const paths = ['my/api/path', '/another/api/path'];
this.server.post('/sys/capabilities-self', (schema, req) => {
const nsHeader = req.requestHeaders['x-vault-namespace'];
const payload = JSON.parse(req.requestBody);
assert.strictEqual(nsHeader, 'ns1', 'request is made in the context of the "ns1" namespace');
assert.propEqual(
payload.paths,
paths,
'paths do not include the namespace because request header manages context'
);
return req.passthrough();
});
await this.capabilities.fetch(paths);
authStub.restore();
});
/*
The setup in this test simulates a user whose root namespace is "ns1" and
they are accessing a resource at a namespace one level deeper in "ns1/child".
*/
test(`if the user's root namespace is a child of "root" and the resource is nested one more level`, async function (assert) {
assert.expect(2);
// the path in the namespace service is always the FULL namespace path of the current context
this.nsSvc.path = 'ns1/child';
const authService = this.owner.lookup('service:auth');
const authStub = sinon.stub(authService, 'authData').value({ userRootNamespace: 'ns1' });
const paths = ['my/api/path', '/another/api/path'];
const expectedPaths = ['child/my/api/path', 'child/another/api/path'];
this.server.post('/sys/capabilities-self', (schema, req) => {
const nsHeader = req.requestHeaders['x-vault-namespace'];
const payload = JSON.parse(req.requestBody);
assert.strictEqual(nsHeader, 'ns1', 'request is made in the context of the "ns1" namespace');
assert.propEqual(payload.paths, expectedPaths, 'paths include the relative namespace');
return req.passthrough();
});
await this.capabilities.fetch(paths);
authStub.restore();
});
/*
The setup in this test simulates a user whose root namespace is "ns1/child" and
they are accessing a resource in the same context.
*/
test(`if the user's root namespace is a grandchild of "root" and the resource is in the same context`, async function (assert) {
assert.expect(2);
// the path in the namespace service is always the FULL namespace path of the current context
this.nsSvc.path = 'ns1/child';
const authService = this.owner.lookup('service:auth');
const authStub = sinon.stub(authService, 'authData').value({ userRootNamespace: 'ns1/child' });
const paths = ['my/api/path', '/another/api/path'];
this.server.post('/sys/capabilities-self', (schema, req) => {
const nsHeader = req.requestHeaders['x-vault-namespace'];
const payload = JSON.parse(req.requestBody);
assert.strictEqual(
nsHeader,
'ns1/child',
'request is made in the context of the "ns1/child" namespace'
);
assert.propEqual(
payload.paths,
paths,
'paths do not include namespace because header manages namespace context'
);
return req.passthrough();
});
await this.capabilities.fetch(paths);
authStub.restore();
});
});
});