Record security feature usage (#67526)
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
c5e9ad513e
commit
a9b2d50e76
16 changed files with 424 additions and 12 deletions
|
@ -10,6 +10,7 @@ import { LicensingPlugin } from './plugin';
|
|||
export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context);
|
||||
|
||||
export * from '../common/types';
|
||||
export { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './services';
|
||||
export * from './types';
|
||||
export { config } from './licensing_config';
|
||||
export { CheckLicense, wrapRouteWithLicenseCheck } from './wrap_route_with_license_check';
|
||||
|
|
|
@ -29,6 +29,7 @@ import { AuthenticationResult } from './authentication_result';
|
|||
import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator';
|
||||
import { DeauthenticationResult } from './deauthentication_result';
|
||||
import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers';
|
||||
import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock';
|
||||
|
||||
function getMockOptions({
|
||||
session,
|
||||
|
@ -54,6 +55,9 @@ function getMockOptions({
|
|||
{ isTLSEnabled: false }
|
||||
),
|
||||
sessionStorageFactory: sessionStorageMock.createFactory<ProviderSession>(),
|
||||
getFeatureUsageService: jest
|
||||
.fn()
|
||||
.mockReturnValue(securityFeatureUsageServiceMock.createStartContract()),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1451,6 +1455,9 @@ describe('Authenticator', () => {
|
|||
);
|
||||
|
||||
expect(mockSessionStorage.set).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails if cannot retrieve user session', async () => {
|
||||
|
@ -1463,6 +1470,9 @@ describe('Authenticator', () => {
|
|||
);
|
||||
|
||||
expect(mockSessionStorage.set).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails if license doesn allow access agreement acknowledgement', async () => {
|
||||
|
@ -1477,6 +1487,9 @@ describe('Authenticator', () => {
|
|||
);
|
||||
|
||||
expect(mockSessionStorage.set).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('properly acknowledges access agreement for the authenticated user', async () => {
|
||||
|
@ -1493,6 +1506,10 @@ describe('Authenticator', () => {
|
|||
type: 'basic',
|
||||
name: 'basic1',
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -38,6 +38,7 @@ import { DeauthenticationResult } from './deauthentication_result';
|
|||
import { Tokens } from './tokens';
|
||||
import { canRedirectRequest } from './can_redirect_request';
|
||||
import { HTTPAuthorizationHeader } from './http_authentication';
|
||||
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
|
||||
|
||||
/**
|
||||
* The shape of the session that is actually stored in the cookie.
|
||||
|
@ -94,6 +95,7 @@ export interface ProviderLoginAttempt {
|
|||
|
||||
export interface AuthenticatorOptions {
|
||||
auditLogger: SecurityAuditLogger;
|
||||
getFeatureUsageService: () => SecurityFeatureUsageServiceStart;
|
||||
getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null;
|
||||
config: Pick<ConfigType, 'session' | 'authc'>;
|
||||
basePath: HttpServiceSetup['basePath'];
|
||||
|
@ -502,6 +504,8 @@ export class Authenticator {
|
|||
currentUser.username,
|
||||
existingSession.provider
|
||||
);
|
||||
|
||||
this.options.getFeatureUsageService().recordPreAccessAgreementUsage();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -42,6 +42,8 @@ import {
|
|||
} from './api_keys';
|
||||
import { SecurityLicense } from '../../common/licensing';
|
||||
import { SecurityAuditLogger } from '../audit';
|
||||
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
|
||||
import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock';
|
||||
|
||||
describe('setupAuthentication()', () => {
|
||||
let mockSetupAuthenticationParams: {
|
||||
|
@ -51,6 +53,7 @@ describe('setupAuthentication()', () => {
|
|||
http: jest.Mocked<CoreSetup['http']>;
|
||||
clusterClient: jest.Mocked<IClusterClient>;
|
||||
license: jest.Mocked<SecurityLicense>;
|
||||
getFeatureUsageService: () => jest.Mocked<SecurityFeatureUsageServiceStart>;
|
||||
};
|
||||
let mockScopedClusterClient: jest.Mocked<PublicMethodsOf<ScopedClusterClient>>;
|
||||
beforeEach(() => {
|
||||
|
@ -69,6 +72,9 @@ describe('setupAuthentication()', () => {
|
|||
clusterClient: elasticsearchServiceMock.createClusterClient(),
|
||||
license: licenseMock.create(),
|
||||
loggers: loggingServiceMock.create(),
|
||||
getFeatureUsageService: jest
|
||||
.fn()
|
||||
.mockReturnValue(securityFeatureUsageServiceMock.createStartContract()),
|
||||
};
|
||||
|
||||
mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
|
|
|
@ -17,6 +17,7 @@ import { ConfigType } from '../config';
|
|||
import { getErrorStatusCode } from '../errors';
|
||||
import { Authenticator, ProviderSession } from './authenticator';
|
||||
import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys';
|
||||
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
|
||||
|
||||
export { canRedirectRequest } from './can_redirect_request';
|
||||
export { Authenticator, ProviderLoginAttempt } from './authenticator';
|
||||
|
@ -37,6 +38,7 @@ export {
|
|||
|
||||
interface SetupAuthenticationParams {
|
||||
auditLogger: SecurityAuditLogger;
|
||||
getFeatureUsageService: () => SecurityFeatureUsageServiceStart;
|
||||
http: CoreSetup['http'];
|
||||
clusterClient: IClusterClient;
|
||||
config: ConfigType;
|
||||
|
@ -48,6 +50,7 @@ export type Authentication = UnwrapPromise<ReturnType<typeof setupAuthentication
|
|||
|
||||
export async function setupAuthentication({
|
||||
auditLogger,
|
||||
getFeatureUsageService,
|
||||
http,
|
||||
clusterClient,
|
||||
config,
|
||||
|
@ -86,6 +89,7 @@ export async function setupAuthentication({
|
|||
|
||||
const authenticator = new Authenticator({
|
||||
auditLogger,
|
||||
getFeatureUsageService,
|
||||
getCurrentUser,
|
||||
clusterClient,
|
||||
basePath: http.basePath,
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { SecurityFeatureUsageService } from './feature_usage_service';
|
||||
|
||||
describe('#setup', () => {
|
||||
it('registers all known security features', () => {
|
||||
const featureUsage = { register: jest.fn() };
|
||||
const securityFeatureUsage = new SecurityFeatureUsageService();
|
||||
securityFeatureUsage.setup({ featureUsage });
|
||||
expect(featureUsage.register).toHaveBeenCalledTimes(2);
|
||||
expect(featureUsage.register.mock.calls.map((c) => c[0])).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"Subfeature privileges",
|
||||
"Pre-access agreement",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start contract', () => {
|
||||
it('notifies when sub-feature privileges are in use', () => {
|
||||
const featureUsage = { notifyUsage: jest.fn(), getLastUsages: jest.fn() };
|
||||
const securityFeatureUsage = new SecurityFeatureUsageService();
|
||||
const startContract = securityFeatureUsage.start({ featureUsage });
|
||||
startContract.recordSubFeaturePrivilegeUsage();
|
||||
expect(featureUsage.notifyUsage).toHaveBeenCalledTimes(1);
|
||||
expect(featureUsage.notifyUsage).toHaveBeenCalledWith('Subfeature privileges');
|
||||
});
|
||||
|
||||
it('notifies when pre-access agreement is used', () => {
|
||||
const featureUsage = { notifyUsage: jest.fn(), getLastUsages: jest.fn() };
|
||||
const securityFeatureUsage = new SecurityFeatureUsageService();
|
||||
const startContract = securityFeatureUsage.start({ featureUsage });
|
||||
startContract.recordPreAccessAgreementUsage();
|
||||
expect(featureUsage.notifyUsage).toHaveBeenCalledTimes(1);
|
||||
expect(featureUsage.notifyUsage).toHaveBeenCalledWith('Pre-access agreement');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FeatureUsageServiceSetup, FeatureUsageServiceStart } from '../../../licensing/server';
|
||||
|
||||
interface SetupDeps {
|
||||
featureUsage: FeatureUsageServiceSetup;
|
||||
}
|
||||
|
||||
interface StartDeps {
|
||||
featureUsage: FeatureUsageServiceStart;
|
||||
}
|
||||
|
||||
export interface SecurityFeatureUsageServiceStart {
|
||||
recordPreAccessAgreementUsage: () => void;
|
||||
recordSubFeaturePrivilegeUsage: () => void;
|
||||
}
|
||||
|
||||
export class SecurityFeatureUsageService {
|
||||
public setup({ featureUsage }: SetupDeps) {
|
||||
featureUsage.register('Subfeature privileges', 'gold');
|
||||
featureUsage.register('Pre-access agreement', 'gold');
|
||||
}
|
||||
|
||||
public start({ featureUsage }: StartDeps): SecurityFeatureUsageServiceStart {
|
||||
return {
|
||||
recordPreAccessAgreementUsage() {
|
||||
featureUsage.notifyUsage('Pre-access agreement');
|
||||
},
|
||||
recordSubFeaturePrivilegeUsage() {
|
||||
featureUsage.notifyUsage('Subfeature privileges');
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
16
x-pack/plugins/security/server/feature_usage/index.mock.ts
Normal file
16
x-pack/plugins/security/server/feature_usage/index.mock.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { SecurityFeatureUsageServiceStart } from './feature_usage_service';
|
||||
|
||||
export const securityFeatureUsageServiceMock = {
|
||||
createStartContract() {
|
||||
return {
|
||||
recordPreAccessAgreementUsage: jest.fn(),
|
||||
recordSubFeaturePrivilegeUsage: jest.fn(),
|
||||
} as jest.Mocked<SecurityFeatureUsageServiceStart>;
|
||||
},
|
||||
};
|
10
x-pack/plugins/security/server/feature_usage/index.ts
Normal file
10
x-pack/plugins/security/server/feature_usage/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export {
|
||||
SecurityFeatureUsageService,
|
||||
SecurityFeatureUsageServiceStart,
|
||||
} from './feature_usage_service';
|
|
@ -41,7 +41,9 @@ describe('Security Plugin', () => {
|
|||
mockClusterClient = elasticsearchServiceMock.createCustomClusterClient();
|
||||
mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient);
|
||||
|
||||
mockDependencies = { licensing: { license$: of({}) } } as PluginSetupDependencies;
|
||||
mockDependencies = ({
|
||||
licensing: { license$: of({}), featureUsage: { register: jest.fn() } },
|
||||
} as unknown) as PluginSetupDependencies;
|
||||
});
|
||||
|
||||
describe('setup()', () => {
|
||||
|
|
|
@ -12,11 +12,15 @@ import {
|
|||
CoreSetup,
|
||||
Logger,
|
||||
PluginInitializerContext,
|
||||
CoreStart,
|
||||
} from '../../../../src/core/server';
|
||||
import { deepFreeze } from '../../../../src/core/server';
|
||||
import { SpacesPluginSetup } from '../../spaces/server';
|
||||
import { PluginSetupContract as FeaturesSetupContract } from '../../features/server';
|
||||
import { LicensingPluginSetup } from '../../licensing/server';
|
||||
import {
|
||||
PluginSetupContract as FeaturesSetupContract,
|
||||
PluginStartContract as FeaturesStartContract,
|
||||
} from '../../features/server';
|
||||
import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server';
|
||||
|
||||
import { Authentication, setupAuthentication } from './authentication';
|
||||
import { Authorization, setupAuthorization } from './authorization';
|
||||
|
@ -26,6 +30,7 @@ import { SecurityLicenseService, SecurityLicense } from '../common/licensing';
|
|||
import { setupSavedObjects } from './saved_objects';
|
||||
import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit';
|
||||
import { elasticsearchClientPlugin } from './elasticsearch_client_plugin';
|
||||
import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage';
|
||||
|
||||
export type SpacesService = Pick<
|
||||
SpacesPluginSetup['spacesService'],
|
||||
|
@ -72,6 +77,11 @@ export interface PluginSetupDependencies {
|
|||
licensing: LicensingPluginSetup;
|
||||
}
|
||||
|
||||
export interface PluginStartDependencies {
|
||||
features: FeaturesStartContract;
|
||||
licensing: LicensingPluginStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents Security Plugin instance that will be managed by the Kibana plugin system.
|
||||
*/
|
||||
|
@ -80,6 +90,16 @@ export class Plugin {
|
|||
private clusterClient?: ICustomClusterClient;
|
||||
private spacesService?: SpacesService | symbol = Symbol('not accessed');
|
||||
private securityLicenseService?: SecurityLicenseService;
|
||||
|
||||
private readonly featureUsageService = new SecurityFeatureUsageService();
|
||||
private featureUsageServiceStart?: SecurityFeatureUsageServiceStart;
|
||||
private readonly getFeatureUsageService = () => {
|
||||
if (!this.featureUsageServiceStart) {
|
||||
throw new Error(`featureUsageServiceStart is not registered!`);
|
||||
}
|
||||
return this.featureUsageServiceStart;
|
||||
};
|
||||
|
||||
private readonly auditService = new AuditService(this.initializerContext.logger.get('audit'));
|
||||
|
||||
private readonly getSpacesService = () => {
|
||||
|
@ -95,7 +115,10 @@ export class Plugin {
|
|||
this.logger = this.initializerContext.logger.get();
|
||||
}
|
||||
|
||||
public async setup(core: CoreSetup, { features, licensing }: PluginSetupDependencies) {
|
||||
public async setup(
|
||||
core: CoreSetup<PluginStartDependencies>,
|
||||
{ features, licensing }: PluginSetupDependencies
|
||||
) {
|
||||
const [config, legacyConfig] = await combineLatest([
|
||||
this.initializerContext.config.create<TypeOf<typeof ConfigSchema>>().pipe(
|
||||
map((rawConfig) =>
|
||||
|
@ -118,11 +141,14 @@ export class Plugin {
|
|||
license$: licensing.license$,
|
||||
});
|
||||
|
||||
this.featureUsageService.setup({ featureUsage: licensing.featureUsage });
|
||||
|
||||
const audit = this.auditService.setup({ license, config: config.audit });
|
||||
const auditLogger = new SecurityAuditLogger(audit.getLogger());
|
||||
|
||||
const authc = await setupAuthentication({
|
||||
auditLogger,
|
||||
getFeatureUsageService: this.getFeatureUsageService,
|
||||
http: core.http,
|
||||
clusterClient: this.clusterClient,
|
||||
config,
|
||||
|
@ -160,6 +186,11 @@ export class Plugin {
|
|||
authc,
|
||||
authz,
|
||||
license,
|
||||
getFeatures: () =>
|
||||
core
|
||||
.getStartServices()
|
||||
.then(([, { features: featuresStart }]) => featuresStart.getFeatures()),
|
||||
getFeatureUsageService: this.getFeatureUsageService,
|
||||
});
|
||||
|
||||
return deepFreeze<SecurityPluginSetup>({
|
||||
|
@ -199,8 +230,11 @@ export class Plugin {
|
|||
});
|
||||
}
|
||||
|
||||
public start() {
|
||||
public start(core: CoreStart, { licensing }: PluginStartDependencies) {
|
||||
this.logger.debug('Starting plugin');
|
||||
this.featureUsageServiceStart = this.featureUsageService.start({
|
||||
featureUsage: licensing.featureUsage,
|
||||
});
|
||||
}
|
||||
|
||||
public stop() {
|
||||
|
@ -216,6 +250,9 @@ export class Plugin {
|
|||
this.securityLicenseService = undefined;
|
||||
}
|
||||
|
||||
if (this.featureUsageServiceStart) {
|
||||
this.featureUsageServiceStart = undefined;
|
||||
}
|
||||
this.auditService.stop();
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@ import {
|
|||
httpServerMock,
|
||||
} from '../../../../../../../src/core/server/mocks';
|
||||
import { routeDefinitionParamsMock } from '../../index.mock';
|
||||
import { Feature } from '../../../../../features/server';
|
||||
import { securityFeatureUsageServiceMock } from '../../../feature_usage/index.mock';
|
||||
|
||||
const application = 'kibana-.kibana';
|
||||
const privilegeMap = {
|
||||
|
@ -47,7 +49,12 @@ interface TestOptions {
|
|||
licenseCheckResult?: LicenseCheck;
|
||||
apiResponses?: Array<() => Promise<unknown>>;
|
||||
payload?: Record<string, any>;
|
||||
asserts: { statusCode: number; result?: Record<string, any>; apiArguments?: unknown[][] };
|
||||
asserts: {
|
||||
statusCode: number;
|
||||
result?: Record<string, any>;
|
||||
apiArguments?: unknown[][];
|
||||
recordSubFeaturePrivilegeUsage?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const putRoleTest = (
|
||||
|
@ -71,6 +78,47 @@ const putRoleTest = (
|
|||
mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse);
|
||||
}
|
||||
|
||||
mockRouteDefinitionParams.getFeatureUsageService.mockReturnValue(
|
||||
securityFeatureUsageServiceMock.createStartContract()
|
||||
);
|
||||
|
||||
mockRouteDefinitionParams.getFeatures.mockResolvedValue([
|
||||
new Feature({
|
||||
id: 'feature_1',
|
||||
name: 'feature 1',
|
||||
app: [],
|
||||
privileges: {
|
||||
all: {
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
read: {
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'sub feature 1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub_feature_privilege_1',
|
||||
name: 'first sub-feature privilege',
|
||||
includeIn: 'none',
|
||||
ui: [],
|
||||
savedObject: { all: [], read: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
definePutRolesRoutes(mockRouteDefinitionParams);
|
||||
const [[{ validate }, handler]] = mockRouteDefinitionParams.router.put.mock.calls;
|
||||
|
||||
|
@ -99,6 +147,16 @@ const putRoleTest = (
|
|||
expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic');
|
||||
|
||||
if (asserts.recordSubFeaturePrivilegeUsage) {
|
||||
expect(
|
||||
mockRouteDefinitionParams.getFeatureUsageService().recordSubFeaturePrivilegeUsage
|
||||
).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
expect(
|
||||
mockRouteDefinitionParams.getFeatureUsageService().recordSubFeaturePrivilegeUsage
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -598,5 +656,131 @@ describe('PUT role', () => {
|
|||
result: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
putRoleTest(`notifies when sub-feature privileges are included`, {
|
||||
name: 'foo-role',
|
||||
payload: {
|
||||
kibana: [
|
||||
{
|
||||
spaces: ['*'],
|
||||
feature: {
|
||||
feature_1: ['sub_feature_privilege_1'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
apiResponses: [async () => ({}), async () => {}],
|
||||
asserts: {
|
||||
recordSubFeaturePrivilegeUsage: true,
|
||||
apiArguments: [
|
||||
['shield.getRole', { name: 'foo-role', ignore: [404] }],
|
||||
[
|
||||
'shield.putRole',
|
||||
{
|
||||
name: 'foo-role',
|
||||
body: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
run_as: [],
|
||||
applications: [
|
||||
{
|
||||
application: 'kibana-.kibana',
|
||||
privileges: ['feature_feature_1.sub_feature_privilege_1'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
metadata: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
statusCode: 204,
|
||||
result: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
putRoleTest(`does not record sub-feature privilege usage for unknown privileges`, {
|
||||
name: 'foo-role',
|
||||
payload: {
|
||||
kibana: [
|
||||
{
|
||||
spaces: ['*'],
|
||||
feature: {
|
||||
feature_1: ['unknown_sub_feature_privilege_1'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
apiResponses: [async () => ({}), async () => {}],
|
||||
asserts: {
|
||||
recordSubFeaturePrivilegeUsage: false,
|
||||
apiArguments: [
|
||||
['shield.getRole', { name: 'foo-role', ignore: [404] }],
|
||||
[
|
||||
'shield.putRole',
|
||||
{
|
||||
name: 'foo-role',
|
||||
body: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
run_as: [],
|
||||
applications: [
|
||||
{
|
||||
application: 'kibana-.kibana',
|
||||
privileges: ['feature_feature_1.unknown_sub_feature_privilege_1'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
metadata: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
statusCode: 204,
|
||||
result: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
putRoleTest(`does not record sub-feature privilege usage for unknown features`, {
|
||||
name: 'foo-role',
|
||||
payload: {
|
||||
kibana: [
|
||||
{
|
||||
spaces: ['*'],
|
||||
feature: {
|
||||
unknown_feature: ['sub_feature_privilege_1'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
apiResponses: [async () => ({}), async () => {}],
|
||||
asserts: {
|
||||
recordSubFeaturePrivilegeUsage: false,
|
||||
apiArguments: [
|
||||
['shield.getRole', { name: 'foo-role', ignore: [404] }],
|
||||
[
|
||||
'shield.putRole',
|
||||
{
|
||||
name: 'foo-role',
|
||||
body: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
run_as: [],
|
||||
applications: [
|
||||
{
|
||||
application: 'kibana-.kibana',
|
||||
privileges: ['feature_unknown_feature.sub_feature_privilege_1'],
|
||||
resources: ['*'],
|
||||
},
|
||||
],
|
||||
metadata: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
statusCode: 204,
|
||||
result: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { Feature } from '../../../../../features/common';
|
||||
import { RouteDefinitionParams } from '../../index';
|
||||
import { createLicensedRouteHandler } from '../../licensed_route_handler';
|
||||
import { wrapIntoCustomErrorResponse } from '../../../errors';
|
||||
|
@ -14,7 +15,37 @@ import {
|
|||
transformPutPayloadToElasticsearchRole,
|
||||
} from './model';
|
||||
|
||||
export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) {
|
||||
const roleGrantsSubFeaturePrivileges = (
|
||||
features: Feature[],
|
||||
role: TypeOf<ReturnType<typeof getPutPayloadSchema>>
|
||||
) => {
|
||||
if (!role.kibana) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subFeaturePrivileges = new Map(
|
||||
features.map((feature) => [
|
||||
feature.id,
|
||||
feature.subFeatures.map((sf) => sf.privilegeGroups.map((pg) => pg.privileges)).flat(2),
|
||||
])
|
||||
);
|
||||
|
||||
const hasAnySubFeaturePrivileges = role.kibana.some((kibanaPrivilege) =>
|
||||
Object.entries(kibanaPrivilege.feature ?? {}).some(([featureId, privileges]) => {
|
||||
return !!subFeaturePrivileges.get(featureId)?.some(({ id }) => privileges.includes(id));
|
||||
})
|
||||
);
|
||||
|
||||
return hasAnySubFeaturePrivileges;
|
||||
};
|
||||
|
||||
export function definePutRolesRoutes({
|
||||
router,
|
||||
authz,
|
||||
clusterClient,
|
||||
getFeatures,
|
||||
getFeatureUsageService,
|
||||
}: RouteDefinitionParams) {
|
||||
router.put(
|
||||
{
|
||||
path: '/api/security/role/{name}',
|
||||
|
@ -46,9 +77,16 @@ export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefi
|
|||
rawRoles[name] ? rawRoles[name].applications : []
|
||||
);
|
||||
|
||||
await clusterClient
|
||||
.asScoped(request)
|
||||
.callAsCurrentUser('shield.putRole', { name: request.params.name, body });
|
||||
const [features] = await Promise.all<Feature[]>([
|
||||
getFeatures(),
|
||||
clusterClient
|
||||
.asScoped(request)
|
||||
.callAsCurrentUser('shield.putRole', { name: request.params.name, body }),
|
||||
]);
|
||||
|
||||
if (roleGrantsSubFeaturePrivileges(features, request.body)) {
|
||||
getFeatureUsageService().recordSubFeaturePrivilegeUsage();
|
||||
}
|
||||
|
||||
return response.noContent();
|
||||
} catch (error) {
|
||||
|
|
|
@ -29,5 +29,7 @@ export const routeDefinitionParamsMock = {
|
|||
authz: authorizationMock.create(),
|
||||
license: licenseMock.create(),
|
||||
httpResources: httpResourcesMock.createRegistrar(),
|
||||
getFeatures: jest.fn(),
|
||||
getFeatureUsageService: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Feature } from '../../../features/server';
|
||||
import {
|
||||
CoreSetup,
|
||||
HttpResources,
|
||||
|
@ -23,6 +24,7 @@ import { defineIndicesRoutes } from './indices';
|
|||
import { defineUsersRoutes } from './users';
|
||||
import { defineRoleMappingRoutes } from './role_mapping';
|
||||
import { defineViewRoutes } from './views';
|
||||
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
|
||||
|
||||
/**
|
||||
* Describes parameters used to define HTTP routes.
|
||||
|
@ -37,6 +39,8 @@ export interface RouteDefinitionParams {
|
|||
authc: Authentication;
|
||||
authz: Authorization;
|
||||
license: SecurityLicense;
|
||||
getFeatures: () => Promise<Feature[]>;
|
||||
getFeatureUsageService: () => SecurityFeatureUsageServiceStart;
|
||||
}
|
||||
|
||||
export function defineRoutes(params: RouteDefinitionParams) {
|
||||
|
|
|
@ -27,7 +27,14 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
|
||||
const response = await supertest.get('/api/licensing/feature_usage').expect(200);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
const testFeaturesResponse = {
|
||||
...response.body,
|
||||
features: response.body.features.filter((feature: { name: string }) =>
|
||||
feature.name.startsWith('Test feature ')
|
||||
),
|
||||
};
|
||||
|
||||
expect(testFeaturesResponse).to.eql({
|
||||
features: [
|
||||
{
|
||||
last_used: null,
|
||||
|
|
Loading…
Reference in a new issue