Introduce mechanism to request default capabilities (#86473) (#87291)

This commit is contained in:
Larry Gregory 2021-01-05 10:15:45 -05:00 committed by GitHub
parent 76d8b49dd3
commit 965eb079d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 179 additions and 63 deletions

View file

@ -31,7 +31,19 @@ How to restrict some capabilities
```ts
// my-plugin/server/plugin.ts
public setup(core: CoreSetup, deps: {}) {
core.capabilities.registerSwitcher((request, capabilities) => {
core.capabilities.registerSwitcher((request, capabilities, useDefaultCapabilities) => {
// useDefaultCapabilities is a special case that switchers typically don't have to concern themselves with.
// The default capabilities are typically the ones you provide in your CapabilitiesProvider, but this flag
// gives each switcher an opportunity to change the default capabilities of other plugins' capabilities.
// For example, you may decide to flip another plugin's capability to false if today is Tuesday,
// but you wouldn't want to do this when we are requesting the default set of capabilities.
if (useDefaultCapabilities) {
return {
somePlugin: {
featureEnabledByDefault: true
}
}
}
if(myPluginApi.shouldRestrictSomePluginBecauseOf(request)) {
return {
somePlugin: {

View file

@ -9,5 +9,5 @@ See [CapabilitiesSetup](./kibana-plugin-core-server.capabilitiessetup.md)
<b>Signature:</b>
```typescript
export declare type CapabilitiesSwitcher = (request: KibanaRequest, uiCapabilities: Capabilities) => Partial<Capabilities> | Promise<Partial<Capabilities>>;
export declare type CapabilitiesSwitcher = (request: KibanaRequest, uiCapabilities: Capabilities, useDefaultCapabilities: boolean) => Partial<Capabilities> | Promise<Partial<Capabilities>>;
```

View file

@ -41,11 +41,36 @@ describe('#start', () => {
http.post.mockReturnValue(Promise.resolve(mockedCapabilities));
});
it('only returns capabilities for given appIds', async () => {
it('requests default capabilities on anonymous paths', async () => {
http.anonymousPaths.isAnonymous.mockReturnValue(true);
const service = new CapabilitiesService();
const appIds = ['app1', 'app2', 'legacyApp1', 'legacyApp2'];
const { capabilities } = await service.start({
http,
appIds: ['app1', 'app2', 'legacyApp1', 'legacyApp2'],
appIds,
});
expect(http.post).toHaveBeenCalledWith('/api/core/capabilities', {
query: {
useDefaultCapabilities: true,
},
body: JSON.stringify({ applications: appIds }),
});
// @ts-expect-error TypeScript knows this shouldn't be possible
expect(() => (capabilities.foo = 'foo')).toThrowError();
});
it('only returns capabilities for given appIds', async () => {
const service = new CapabilitiesService();
const appIds = ['app1', 'app2', 'legacyApp1', 'legacyApp2'];
const { capabilities } = await service.start({
http,
appIds,
});
expect(http.post).toHaveBeenCalledWith('/api/core/capabilities', {
body: JSON.stringify({ applications: appIds }),
});
// @ts-expect-error TypeScript knows this shouldn't be possible

View file

@ -38,7 +38,9 @@ export interface CapabilitiesStart {
*/
export class CapabilitiesService {
public async start({ appIds, http }: StartDeps): Promise<CapabilitiesStart> {
const useDefaultCapabilities = http.anonymousPaths.isAnonymous(window.location.pathname);
const capabilities = await http.post<Capabilities>('/api/core/capabilities', {
query: useDefaultCapabilities ? { useDefaultCapabilities } : undefined,
body: JSON.stringify({ applications: appIds }),
});

View file

@ -76,7 +76,19 @@ export interface CapabilitiesSetup {
* ```ts
* // my-plugin/server/plugin.ts
* public setup(core: CoreSetup, deps: {}) {
* core.capabilities.registerSwitcher((request, capabilities) => {
* core.capabilities.registerSwitcher((request, capabilities, useDefaultCapabilities) => {
* // useDefaultCapabilities is a special case that switchers typically don't have to concern themselves with.
* // The default capabilities are typically the ones you provide in your CapabilitiesProvider, but this flag
* // gives each switcher an opportunity to change the default capabilities of other plugins' capabilities.
* // For example, you may decide to flip another plugin's capability to false if today is Tuesday,
* // but you wouldn't want to do this when we are requesting the default set of capabilities.
* if (useDefaultCapabilities) {
* return {
* somePlugin: {
* featureEnabledByDefault: true
* }
* }
* }
* if(myPluginApi.shouldRestrictSomePluginBecauseOf(request)) {
* return {
* somePlugin: {
@ -150,7 +162,7 @@ export class CapabilitiesService {
public start(): CapabilitiesStart {
return {
resolveCapabilities: (request) => this.resolveCapabilities(request, []),
resolveCapabilities: (request) => this.resolveCapabilities(request, [], false),
};
}
}

View file

@ -72,17 +72,57 @@ describe('CapabilitiesService', () => {
`);
});
it('uses the service capabilities providers', async () => {
serviceSetup.registerProvider(() => ({
it('uses the service capabilities providers and switchers', async () => {
const getInitialCapabilities = () => ({
catalogue: {
something: true,
},
}));
management: {},
navLinks: {},
});
serviceSetup.registerProvider(() => getInitialCapabilities());
const switcher = jest.fn((_, capabilities) => capabilities);
serviceSetup.registerSwitcher(switcher);
const result = await supertest(httpSetup.server.listener)
.post('/api/core/capabilities')
.send({ applications: [] })
.expect(200);
expect(switcher).toHaveBeenCalledTimes(1);
expect(switcher).toHaveBeenCalledWith(expect.anything(), getInitialCapabilities(), false);
expect(result.body).toMatchInlineSnapshot(`
Object {
"catalogue": Object {
"something": true,
},
"management": Object {},
"navLinks": Object {},
}
`);
});
it('passes useDefaultCapabilities to registered switchers', async () => {
const getInitialCapabilities = () => ({
catalogue: {
something: true,
},
management: {},
navLinks: {},
});
serviceSetup.registerProvider(() => getInitialCapabilities());
const switcher = jest.fn((_, capabilities) => capabilities);
serviceSetup.registerSwitcher(switcher);
const result = await supertest(httpSetup.server.listener)
.post('/api/core/capabilities?useDefaultCapabilities=true')
.send({ applications: [] })
.expect(200);
expect(switcher).toHaveBeenCalledTimes(1);
expect(switcher).toHaveBeenCalledWith(expect.anything(), getInitialCapabilities(), true);
expect(result.body).toMatchInlineSnapshot(`
Object {
"catalogue": Object {

View file

@ -36,7 +36,7 @@ describe('resolveCapabilities', () => {
});
it('returns the initial capabilities if no switcher are used', async () => {
const result = await resolveCapabilities(defaultCaps, [], request, []);
const result = await resolveCapabilities(defaultCaps, [], request, [], true);
expect(result).toEqual(defaultCaps);
});
@ -55,7 +55,7 @@ describe('resolveCapabilities', () => {
A: false,
},
});
const result = await resolveCapabilities(caps, [switcher], request, []);
const result = await resolveCapabilities(caps, [switcher], request, [], true);
expect(result).toMatchInlineSnapshot(`
Object {
"catalogue": Object {
@ -83,7 +83,7 @@ describe('resolveCapabilities', () => {
A: false,
},
});
await resolveCapabilities(caps, [switcher], request, []);
await resolveCapabilities(caps, [switcher], request, [], true);
expect(caps.catalogue).toEqual({
A: true,
B: true,
@ -105,7 +105,7 @@ describe('resolveCapabilities', () => {
C: false,
},
});
const result = await resolveCapabilities(caps, [switcher], request, []);
const result = await resolveCapabilities(caps, [switcher], request, [], true);
expect(result.catalogue).toEqual({
A: true,
B: true,
@ -127,7 +127,7 @@ describe('resolveCapabilities', () => {
.filter(([key]) => key !== 'B')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
});
const result = await resolveCapabilities(caps, [switcher], request, []);
const result = await resolveCapabilities(caps, [switcher], request, [], true);
expect(result.catalogue).toEqual({
A: true,
B: true,
@ -153,7 +153,7 @@ describe('resolveCapabilities', () => {
record: false,
},
});
const result = await resolveCapabilities(caps, [switcher], request, []);
const result = await resolveCapabilities(caps, [switcher], request, [], true);
expect(result.section).toEqual({
boolean: true,
record: {

View file

@ -23,7 +23,8 @@ import { KibanaRequest } from '../http';
export type CapabilitiesResolver = (
request: KibanaRequest,
applications: string[]
applications: string[],
useDefaultCapabilities: boolean
) => Promise<Capabilities>;
export const getCapabilitiesResolver = (
@ -31,16 +32,24 @@ export const getCapabilitiesResolver = (
switchers: () => CapabilitiesSwitcher[]
): CapabilitiesResolver => async (
request: KibanaRequest,
applications: string[]
applications: string[],
useDefaultCapabilities: boolean
): Promise<Capabilities> => {
return resolveCapabilities(capabilities(), switchers(), request, applications);
return resolveCapabilities(
capabilities(),
switchers(),
request,
applications,
useDefaultCapabilities
);
};
export const resolveCapabilities = async (
capabilities: Capabilities,
switchers: CapabilitiesSwitcher[],
request: KibanaRequest,
applications: string[]
applications: string[],
useDefaultCapabilities: boolean
): Promise<Capabilities> => {
const mergedCaps = cloneDeep({
...capabilities,
@ -54,7 +63,7 @@ export const resolveCapabilities = async (
});
return switchers.reduce(async (caps, switcher) => {
const resolvedCaps = await caps;
const changes = await switcher(request, resolvedCaps);
const changes = await switcher(request, resolvedCaps, useDefaultCapabilities);
return recursiveApplyChanges(resolvedCaps, changes);
}, Promise.resolve(mergedCaps));
};

View file

@ -29,14 +29,18 @@ export function registerCapabilitiesRoutes(router: IRouter, resolver: Capabiliti
authRequired: 'optional',
},
validate: {
query: schema.object({
useDefaultCapabilities: schema.boolean({ defaultValue: false }),
}),
body: schema.object({
applications: schema.arrayOf(schema.string()),
}),
},
},
async (ctx, req, res) => {
const { useDefaultCapabilities } = req.query;
const { applications } = req.body;
const capabilities = await resolver(req, applications);
const capabilities = await resolver(req, applications, useDefaultCapabilities);
return res.ok({
body: capabilities,
});

View file

@ -34,5 +34,6 @@ export type CapabilitiesProvider = () => Partial<Capabilities>;
*/
export type CapabilitiesSwitcher = (
request: KibanaRequest,
uiCapabilities: Capabilities
uiCapabilities: Capabilities,
useDefaultCapabilities: boolean
) => Partial<Capabilities> | Promise<Partial<Capabilities>>;

View file

@ -310,7 +310,7 @@ export interface CapabilitiesStart {
}
// @public
export type CapabilitiesSwitcher = (request: KibanaRequest, uiCapabilities: Capabilities) => Partial<Capabilities> | Promise<Partial<Capabilities>>;
export type CapabilitiesSwitcher = (request: KibanaRequest, uiCapabilities: Capabilities, useDefaultCapabilities: boolean) => Partial<Capabilities> | Promise<Partial<Capabilities>>;
// @alpha
export const config: {

View file

@ -10,16 +10,9 @@ import { nextTick } from '@kbn/test/jest';
describe('SpacesManager', () => {
describe('#constructor', () => {
it('attempts to retrieve the active space', () => {
it('does not attempt to retrieve the active space', () => {
const coreStart = coreMock.createStart();
new SpacesManager(coreStart.http);
expect(coreStart.http.get).toHaveBeenCalledWith('/internal/spaces/_active_space');
});
it('does not retrieve the active space if on an anonymous path', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true);
new SpacesManager(coreStart.http);
expect(coreStart.http.get).not.toHaveBeenCalled();
});
});
@ -32,6 +25,7 @@ describe('SpacesManager', () => {
name: 'my space',
});
const spacesManager = new SpacesManager(coreStart.http);
await spacesManager.getActiveSpace();
expect(coreStart.http.get).toHaveBeenCalledWith('/internal/spaces/_active_space');
await nextTick();
@ -50,7 +44,7 @@ describe('SpacesManager', () => {
const spacesManager = new SpacesManager(coreStart.http);
expect(coreStart.http.get).not.toHaveBeenCalled();
expect(() => spacesManager.getActiveSpace()).toThrowErrorMatchingInlineSnapshot(
expect(() => spacesManager.getActiveSpace()).rejects.toThrowErrorMatchingInlineSnapshot(
`"Cannot retrieve the active space for anonymous paths"`
);
});
@ -68,9 +62,6 @@ describe('SpacesManager', () => {
});
const spacesManager = new SpacesManager(coreStart.http);
expect(coreStart.http.get).toHaveBeenCalledWith('/internal/spaces/_active_space');
await nextTick();
const activeSpace = await spacesManager.getActiveSpace();
expect(activeSpace).toEqual({
@ -99,7 +90,7 @@ describe('SpacesManager', () => {
expect(() =>
spacesManager.getActiveSpace({ forceRefresh: true })
).toThrowErrorMatchingInlineSnapshot(
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Cannot retrieve the active space for anonymous paths"`
);
});
@ -111,10 +102,9 @@ describe('SpacesManager', () => {
const shareToAllSpaces = Symbol();
coreStart.http.get.mockResolvedValue({ shareToAllSpaces });
const spacesManager = new SpacesManager(coreStart.http);
expect(coreStart.http.get).toHaveBeenCalledTimes(1); // initial call to get active space
const result = await spacesManager.getShareSavedObjectPermissions('foo');
expect(coreStart.http.get).toHaveBeenCalledTimes(2);
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.http.get).toHaveBeenLastCalledWith(
'/internal/security/_share_saved_object_permissions',
{
@ -126,17 +116,15 @@ describe('SpacesManager', () => {
it('allows the share if security is disabled', async () => {
const coreStart = coreMock.createStart();
coreStart.http.get.mockResolvedValueOnce({});
coreStart.http.get.mockRejectedValueOnce({
body: {
statusCode: 404,
},
});
const spacesManager = new SpacesManager(coreStart.http);
expect(coreStart.http.get).toHaveBeenCalledTimes(1); // initial call to get active space
const result = await spacesManager.getShareSavedObjectPermissions('foo');
expect(coreStart.http.get).toHaveBeenCalledTimes(2);
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.http.get).toHaveBeenLastCalledWith(
'/internal/security/_share_saved_object_permissions',
{
@ -148,16 +136,14 @@ describe('SpacesManager', () => {
it('throws all other errors', async () => {
const coreStart = coreMock.createStart();
coreStart.http.get.mockResolvedValueOnce({});
coreStart.http.get.mockRejectedValueOnce(new Error('Get out of here!'));
const spacesManager = new SpacesManager(coreStart.http);
expect(coreStart.http.get).toHaveBeenCalledTimes(1); // initial call to get active space
await expect(
spacesManager.getShareSavedObjectPermissions('foo')
).rejects.toThrowErrorMatchingInlineSnapshot(`"Get out of here!"`);
expect(coreStart.http.get).toHaveBeenCalledTimes(2);
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.http.get).toHaveBeenLastCalledWith(
'/internal/security/_share_saved_object_permissions',
{

View file

@ -22,16 +22,21 @@ export class SpacesManager {
private readonly serverBasePath: string;
public readonly onActiveSpaceChange$: Observable<Space>;
private readonly _onActiveSpaceChange$: Observable<Space>;
constructor(private readonly http: HttpSetup) {
this.serverBasePath = http.basePath.serverBasePath;
this.onActiveSpaceChange$ = this.activeSpace$
this._onActiveSpaceChange$ = this.activeSpace$
.asObservable()
.pipe(skipWhile((v: Space | null) => v == null)) as Observable<Space>;
}
this.refreshActiveSpace();
public get onActiveSpaceChange$() {
if (!this.activeSpace$.value) {
this.refreshActiveSpace();
}
return this._onActiveSpaceChange$;
}
public async getSpaces(options: GetAllSpacesOptions = {}): Promise<GetSpaceResult[]> {
@ -44,14 +49,14 @@ export class SpacesManager {
return await this.http.get(`/api/spaces/space/${encodeURIComponent(id)}`);
}
public getActiveSpace({ forceRefresh = false } = {}) {
public async getActiveSpace({ forceRefresh = false } = {}) {
if (this.isAnonymousPath()) {
throw new Error(`Cannot retrieve the active space for anonymous paths`);
}
if (!forceRefresh && this.activeSpace$.value) {
return Promise.resolve(this.activeSpace$.value);
if (forceRefresh || !this.activeSpace$.value) {
await this.refreshActiveSpace();
}
return this.http.get('/internal/spaces/_active_space') as Promise<Space>;
return this.activeSpace$.value!;
}
public async createSpace(space: Space) {
@ -149,7 +154,7 @@ export class SpacesManager {
if (this.isAnonymousPath()) {
return;
}
const activeSpace = await this.getActiveSpace({ forceRefresh: true });
const activeSpace = await this.http.get('/internal/spaces/_active_space');
this.activeSpace$.next(activeSpace);
}

View file

@ -152,7 +152,7 @@ describe('capabilitiesSwitcher', () => {
const { switcher } = setup(space);
const request = httpServerMock.createKibanaRequest();
const result = await switcher(request, capabilities);
const result = await switcher(request, capabilities, false);
expect(result).toEqual(buildCapabilities());
});
@ -166,12 +166,31 @@ describe('capabilitiesSwitcher', () => {
const capabilities = buildCapabilities();
const { switcher } = setup(space);
const { switcher, spacesService } = setup(space);
const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false });
const result = await switcher(request, capabilities);
const result = await switcher(request, capabilities, false);
expect(result).toEqual(buildCapabilities());
expect(spacesService.getActiveSpace).not.toHaveBeenCalled();
});
it('does not toggle capabilities when the default capabilities are requested', async () => {
const space: Space = {
id: 'space',
name: '',
disabledFeatures: ['feature_1', 'feature_2', 'feature_3'],
};
const capabilities = buildCapabilities();
const { switcher, spacesService } = setup(space);
const request = httpServerMock.createKibanaRequest();
const result = await switcher(request, capabilities, true);
expect(result).toEqual(buildCapabilities());
expect(spacesService.getActiveSpace).not.toHaveBeenCalled();
});
it('logs a debug message, and does not toggle capabilities if an error is encountered', async () => {
@ -188,7 +207,7 @@ describe('capabilitiesSwitcher', () => {
spacesService.getActiveSpace.mockRejectedValue(new Error('Something terrible happened'));
const result = await switcher(request, capabilities);
const result = await switcher(request, capabilities, false);
expect(result).toEqual(buildCapabilities());
expect(logger.debug).toHaveBeenCalledWith(
@ -207,7 +226,7 @@ describe('capabilitiesSwitcher', () => {
const { switcher } = setup(space);
const request = httpServerMock.createKibanaRequest();
const result = await switcher(request, capabilities);
const result = await switcher(request, capabilities, false);
expect(result).toEqual(buildCapabilities());
});
@ -223,7 +242,7 @@ describe('capabilitiesSwitcher', () => {
const { switcher } = setup(space);
const request = httpServerMock.createKibanaRequest();
const result = await switcher(request, capabilities);
const result = await switcher(request, capabilities, false);
const expectedCapabilities = buildCapabilities();
@ -247,7 +266,7 @@ describe('capabilitiesSwitcher', () => {
const { switcher } = setup(space);
const request = httpServerMock.createKibanaRequest();
const result = await switcher(request, capabilities);
const result = await switcher(request, capabilities, false);
const expectedCapabilities = buildCapabilities();
@ -274,7 +293,7 @@ describe('capabilitiesSwitcher', () => {
const { switcher } = setup(space);
const request = httpServerMock.createKibanaRequest();
const result = await switcher(request, capabilities);
const result = await switcher(request, capabilities, false);
const expectedCapabilities = buildCapabilities();

View file

@ -15,10 +15,11 @@ export function setupCapabilitiesSwitcher(
getSpacesService: () => SpacesServiceStart,
logger: Logger
): CapabilitiesSwitcher {
return async (request, capabilities) => {
const isAnonymousRequest = !request.route.options.authRequired;
return async (request, capabilities, useDefaultCapabilities) => {
const isAuthRequiredOrOptional = !request.route.options.authRequired;
const shouldNotToggleCapabilities = isAuthRequiredOrOptional || useDefaultCapabilities;
if (isAnonymousRequest) {
if (shouldNotToggleCapabilities) {
return capabilities;
}