Record security feature usage (#67526)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Larry Gregory 2020-06-04 12:29:28 -04:00 committed by GitHub
parent c5e9ad513e
commit a9b2d50e76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 424 additions and 12 deletions

View file

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

View file

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

View file

@ -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();
}
/**

View file

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

View file

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

View file

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

View file

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

View 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>;
},
};

View 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';

View file

@ -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()', () => {

View file

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

View file

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

View file

@ -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) {

View file

@ -29,5 +29,7 @@ export const routeDefinitionParamsMock = {
authz: authorizationMock.create(),
license: licenseMock.create(),
httpResources: httpResourcesMock.createRegistrar(),
getFeatures: jest.fn(),
getFeatureUsageService: jest.fn(),
}),
};

View file

@ -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) {

View file

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