* non-working POC for privilege deprecations * wip to be bale to deprecated sub feature case in security solutions * finalyze deprecations of cases sub feature in security solutions * only adding the deprecation servces in security * add test + translation * Update x-pack/plugins/security/server/deprecations/privilege_deprecations.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * joe reviews * renaming + double check Co-authored-by: Larry Gregory <larry.gregory@elastic.co> Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Larry Gregory <larry.gregory@elastic.co> Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
parent
ffeefb5f94
commit
748677ead0
25
x-pack/plugins/security/common/model/deprecations.ts
Normal file
25
x-pack/plugins/security/common/model/deprecations.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import type { DeprecationsDetails, GetDeprecationsContext } from '../../../../../src/core/server';
|
||||
import type { Role } from './role';
|
||||
|
||||
export interface PrivilegeDeprecationsRolesByFeatureIdResponse {
|
||||
roles?: Role[];
|
||||
errors?: DeprecationsDetails[];
|
||||
}
|
||||
|
||||
export interface PrivilegeDeprecationsRolesByFeatureIdRequest {
|
||||
context: GetDeprecationsContext;
|
||||
featureId: string;
|
||||
}
|
||||
export interface PrivilegeDeprecationsService {
|
||||
getKibanaRolesByFeatureId: (
|
||||
args: PrivilegeDeprecationsRolesByFeatureIdRequest
|
||||
) => Promise<PrivilegeDeprecationsRolesByFeatureIdResponse>;
|
||||
}
|
|
@ -33,3 +33,8 @@ export {
|
|||
RoleTemplate,
|
||||
RoleMapping,
|
||||
} from './role_mapping';
|
||||
export {
|
||||
PrivilegeDeprecationsRolesByFeatureIdRequest,
|
||||
PrivilegeDeprecationsRolesByFeatureIdResponse,
|
||||
PrivilegeDeprecationsService,
|
||||
} from './deprecations';
|
||||
|
|
|
@ -13,3 +13,4 @@ export {
|
|||
} from './authorization_service';
|
||||
export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges';
|
||||
export { CheckPrivilegesPayload } from './types';
|
||||
export { transformElasticsearchRoleToRole, ElasticsearchRole } from './roles';
|
||||
|
|
|
@ -8,10 +8,10 @@
|
|||
import {
|
||||
GLOBAL_RESOURCE,
|
||||
RESERVED_PRIVILEGES_APPLICATION_WILDCARD,
|
||||
} from '../../../../../common/constants';
|
||||
import type { Role, RoleKibanaPrivilege } from '../../../../../common/model';
|
||||
import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer';
|
||||
import { ResourceSerializer } from '../../../../authorization/resource_serializer';
|
||||
} from '../../../common/constants';
|
||||
import type { Role, RoleKibanaPrivilege } from '../../../common/model';
|
||||
import { PrivilegeSerializer } from '../privilege_serializer';
|
||||
import { ResourceSerializer } from '../resource_serializer';
|
||||
|
||||
export type ElasticsearchRole = Pick<Role, 'name' | 'metadata' | 'transient_metadata'> & {
|
||||
applications: Array<{
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { transformElasticsearchRoleToRole, ElasticsearchRole } from './elasticsearch_role';
|
12
x-pack/plugins/security/server/deprecations/index.ts
Normal file
12
x-pack/plugins/security/server/deprecations/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/**
|
||||
* getKibanaRolesByFeature
|
||||
*/
|
||||
|
||||
export { getPrivilegeDeprecationsService } from './privilege_deprecations';
|
|
@ -0,0 +1,284 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { GetDeprecationsContext } from 'src/core/server';
|
||||
import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks';
|
||||
|
||||
import { getPrivilegeDeprecationsService } from '.';
|
||||
import { licenseMock } from '../../common/licensing/index.mock';
|
||||
|
||||
const kibanaIndexName = '.a-kibana-index';
|
||||
const application = `kibana-${kibanaIndexName}`;
|
||||
|
||||
describe('#getPrivilegeDeprecationsService', () => {
|
||||
describe('#getKibanaRolesByFeatureId', () => {
|
||||
const mockAsCurrentUser = elasticsearchServiceMock.createScopedClusterClient();
|
||||
const mockLicense = licenseMock.create();
|
||||
const mockLogger = loggingSystemMock.createLogger();
|
||||
const authz = { applicationName: application };
|
||||
|
||||
const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService(
|
||||
authz,
|
||||
mockLicense,
|
||||
mockLogger
|
||||
);
|
||||
|
||||
it('happy path to find siem roles with feature_siem privileges', async () => {
|
||||
mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue(
|
||||
elasticsearchServiceMock.createSuccessTransportRequestPromise({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application,
|
||||
privileges: ['feature_siem.all', 'feature_siem.cases_read'],
|
||||
resources: ['space:securitySolutions'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: {
|
||||
_reserved: true,
|
||||
},
|
||||
transient_metadata: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const mockContext = {
|
||||
esClient: mockAsCurrentUser,
|
||||
savedObjectsClient: jest.fn(),
|
||||
} as unknown as GetDeprecationsContext;
|
||||
|
||||
const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' });
|
||||
expect(resp).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"roles": Array [
|
||||
Object {
|
||||
"_transform_error": Array [],
|
||||
"_unrecognized_applications": Array [],
|
||||
"elasticsearch": Object {
|
||||
"cluster": Array [],
|
||||
"indices": Array [],
|
||||
"run_as": Array [],
|
||||
},
|
||||
"kibana": Array [
|
||||
Object {
|
||||
"base": Array [],
|
||||
"feature": Object {
|
||||
"siem": Array [
|
||||
"all",
|
||||
"cases_read",
|
||||
],
|
||||
},
|
||||
"spaces": Array [
|
||||
"securitySolutions",
|
||||
],
|
||||
},
|
||||
],
|
||||
"metadata": Object {
|
||||
"_reserved": true,
|
||||
},
|
||||
"name": "first_role",
|
||||
"transient_metadata": Object {
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('happy path to find siem roles with feature_siem and feature_foo and feature_bar privileges', async () => {
|
||||
mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue(
|
||||
elasticsearchServiceMock.createSuccessTransportRequestPromise({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application,
|
||||
privileges: [
|
||||
'feature_foo.foo-privilege-1',
|
||||
'feature_foo.foo-privilege-2',
|
||||
'feature_bar.bar-privilege-1',
|
||||
'feature_siem.all',
|
||||
'feature_siem.cases_read',
|
||||
],
|
||||
resources: ['space:securitySolutions'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: {
|
||||
_reserved: true,
|
||||
},
|
||||
transient_metadata: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const mockContext = {
|
||||
esClient: mockAsCurrentUser,
|
||||
savedObjectsClient: jest.fn(),
|
||||
} as unknown as GetDeprecationsContext;
|
||||
|
||||
const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' });
|
||||
expect(resp).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"roles": Array [
|
||||
Object {
|
||||
"_transform_error": Array [],
|
||||
"_unrecognized_applications": Array [],
|
||||
"elasticsearch": Object {
|
||||
"cluster": Array [],
|
||||
"indices": Array [],
|
||||
"run_as": Array [],
|
||||
},
|
||||
"kibana": Array [
|
||||
Object {
|
||||
"base": Array [],
|
||||
"feature": Object {
|
||||
"bar": Array [
|
||||
"bar-privilege-1",
|
||||
],
|
||||
"foo": Array [
|
||||
"foo-privilege-1",
|
||||
"foo-privilege-2",
|
||||
],
|
||||
"siem": Array [
|
||||
"all",
|
||||
"cases_read",
|
||||
],
|
||||
},
|
||||
"spaces": Array [
|
||||
"securitySolutions",
|
||||
],
|
||||
},
|
||||
],
|
||||
"metadata": Object {
|
||||
"_reserved": true,
|
||||
},
|
||||
"name": "first_role",
|
||||
"transient_metadata": Object {
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('happy path to NOT find siem roles with and feature_foo and feature_bar privileges', async () => {
|
||||
mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue(
|
||||
elasticsearchServiceMock.createSuccessTransportRequestPromise({
|
||||
first_role: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
applications: [
|
||||
{
|
||||
application,
|
||||
privileges: [
|
||||
'feature_foo.foo-privilege-1',
|
||||
'feature_foo.foo-privilege-2',
|
||||
'feature_bar.bar-privilege-1',
|
||||
],
|
||||
resources: ['space:securitySolutions'],
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
metadata: {
|
||||
_reserved: true,
|
||||
},
|
||||
transient_metadata: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const mockContext = {
|
||||
esClient: mockAsCurrentUser,
|
||||
savedObjectsClient: jest.fn(),
|
||||
} as unknown as GetDeprecationsContext;
|
||||
|
||||
const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' });
|
||||
expect(resp).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"roles": Array [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('unhappy path with status code 400, we should have the attribute errors', async () => {
|
||||
mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue(
|
||||
elasticsearchServiceMock.createErrorTransportRequestPromise({
|
||||
message: 'Test error',
|
||||
statusCode: 400,
|
||||
})
|
||||
);
|
||||
|
||||
const mockContext = {
|
||||
esClient: mockAsCurrentUser,
|
||||
savedObjectsClient: jest.fn(),
|
||||
} as unknown as GetDeprecationsContext;
|
||||
|
||||
const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' });
|
||||
expect(resp).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"correctiveActions": Object {
|
||||
"manualSteps": Array [
|
||||
"A user with the \\"manage_security\\" cluster privilege is required to perform this check.",
|
||||
],
|
||||
},
|
||||
"level": "fetch_error",
|
||||
"message": "Error retrieving roles for privilege deprecations: Test error",
|
||||
"title": "Error in privilege deprecations services",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('unhappy path with status code 403, we should have unauthorized message in the attribute errors', async () => {
|
||||
mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue(
|
||||
elasticsearchServiceMock.createErrorTransportRequestPromise({
|
||||
message: 'Test error',
|
||||
statusCode: 403,
|
||||
})
|
||||
);
|
||||
|
||||
const mockContext = {
|
||||
esClient: mockAsCurrentUser,
|
||||
savedObjectsClient: jest.fn(),
|
||||
} as unknown as GetDeprecationsContext;
|
||||
|
||||
const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' });
|
||||
expect(resp).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"correctiveActions": Object {
|
||||
"manualSteps": Array [
|
||||
"A user with the \\"manage_security\\" cluster privilege is required to perform this check.",
|
||||
],
|
||||
},
|
||||
"level": "fetch_error",
|
||||
"message": "You must have the 'manage_security' cluster privilege to fix role deprecations.",
|
||||
"title": "Error in privilege deprecations services",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Logger } from 'src/core/server';
|
||||
|
||||
import type { SecurityLicense } from '../../common/licensing';
|
||||
import type {
|
||||
PrivilegeDeprecationsRolesByFeatureIdRequest,
|
||||
PrivilegeDeprecationsRolesByFeatureIdResponse,
|
||||
} from '../../common/model';
|
||||
import { transformElasticsearchRoleToRole } from '../authorization';
|
||||
import type { AuthorizationServiceSetupInternal, ElasticsearchRole } from '../authorization';
|
||||
import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
|
||||
|
||||
export const getPrivilegeDeprecationsService = (
|
||||
authz: Pick<AuthorizationServiceSetupInternal, 'applicationName'>,
|
||||
license: SecurityLicense,
|
||||
logger: Logger
|
||||
) => {
|
||||
const getKibanaRolesByFeatureId = async ({
|
||||
context,
|
||||
featureId,
|
||||
}: PrivilegeDeprecationsRolesByFeatureIdRequest): Promise<PrivilegeDeprecationsRolesByFeatureIdResponse> => {
|
||||
// Nothing to do if security is disabled
|
||||
if (!license.isEnabled()) {
|
||||
return {
|
||||
roles: [],
|
||||
};
|
||||
}
|
||||
let kibanaRoles;
|
||||
try {
|
||||
const { body: elasticsearchRoles } = await context.esClient.asCurrentUser.security.getRole<
|
||||
Record<string, ElasticsearchRole>
|
||||
>();
|
||||
kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) =>
|
||||
transformElasticsearchRoleToRole(
|
||||
// @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]`
|
||||
elasticsearchRole,
|
||||
roleName,
|
||||
authz.applicationName
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
const statusCode = getErrorStatusCode(e);
|
||||
const isUnauthorized = statusCode === 403;
|
||||
const message = isUnauthorized
|
||||
? i18n.translate('xpack.security.privilegeDeprecationsService.error.unauthorized.message', {
|
||||
defaultMessage: `You must have the 'manage_security' cluster privilege to fix role deprecations.`,
|
||||
})
|
||||
: i18n.translate(
|
||||
'xpack.security.privilegeDeprecationsService.error.retrievingRoles.message',
|
||||
{
|
||||
defaultMessage: `Error retrieving roles for privilege deprecations: {message}`,
|
||||
values: {
|
||||
message: getDetailedErrorMessage(e),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isUnauthorized) {
|
||||
logger.warn(
|
||||
`Failed to retrieve roles when checking for deprecations: the manage_security cluster privilege is required`
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`Failed to retrieve roles when checking for deprecations, unexpected error: ${getDetailedErrorMessage(
|
||||
e
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
title: i18n.translate('xpack.security.privilegeDeprecationsService.error.title', {
|
||||
defaultMessage: `Error in privilege deprecations services`,
|
||||
}),
|
||||
level: 'fetch_error',
|
||||
message,
|
||||
correctiveActions: {
|
||||
manualSteps: [
|
||||
i18n.translate('xpack.security.privilegeDeprecationsService.manualSteps.message', {
|
||||
defaultMessage:
|
||||
'A user with the "manage_security" cluster privilege is required to perform this check.',
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
roles: kibanaRoles.filter((role) =>
|
||||
role.kibana.find((privilege) => Object.hasOwnProperty.call(privilege.feature, featureId))
|
||||
),
|
||||
};
|
||||
};
|
||||
return Object.freeze({
|
||||
getKibanaRolesByFeatureId,
|
||||
});
|
||||
};
|
|
@ -28,6 +28,9 @@ function createSetupMock() {
|
|||
},
|
||||
registerSpacesService: jest.fn(),
|
||||
license: licenseMock.create(),
|
||||
privilegeDeprecationsService: {
|
||||
getKibanaRolesByFeatureId: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -123,6 +123,9 @@ describe('Security Plugin', () => {
|
|||
"isEnabled": [Function],
|
||||
"isLicenseAvailable": [Function],
|
||||
},
|
||||
"privilegeDeprecationsService": Object {
|
||||
"getKibanaRolesByFeatureId": [Function],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
|
|
@ -30,7 +30,7 @@ import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server';
|
|||
import type { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server';
|
||||
import type { SecurityLicense } from '../common/licensing';
|
||||
import { SecurityLicenseService } from '../common/licensing';
|
||||
import type { AuthenticatedUser } from '../common/model';
|
||||
import type { AuthenticatedUser, PrivilegeDeprecationsService } from '../common/model';
|
||||
import type { AnonymousAccessServiceStart } from './anonymous_access';
|
||||
import { AnonymousAccessService } from './anonymous_access';
|
||||
import type { AuditServiceSetup } from './audit';
|
||||
|
@ -44,6 +44,7 @@ import type { AuthorizationServiceSetup, AuthorizationServiceSetupInternal } fro
|
|||
import { AuthorizationService } from './authorization';
|
||||
import type { ConfigSchema, ConfigType } from './config';
|
||||
import { createConfig } from './config';
|
||||
import { getPrivilegeDeprecationsService } from './deprecations';
|
||||
import { ElasticsearchService } from './elasticsearch';
|
||||
import type { SecurityFeatureUsageServiceStart } from './feature_usage';
|
||||
import { SecurityFeatureUsageService } from './feature_usage';
|
||||
|
@ -85,6 +86,10 @@ export interface SecurityPluginSetup {
|
|||
* Exposes services for audit logging.
|
||||
*/
|
||||
audit: AuditServiceSetup;
|
||||
/**
|
||||
* Exposes services to access kibana roles per feature id with the GetDeprecationsContext
|
||||
*/
|
||||
privilegeDeprecationsService: PrivilegeDeprecationsService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -321,9 +326,7 @@ export class SecurityPlugin
|
|||
asScoped: this.auditSetup.asScoped,
|
||||
getLogger: this.auditSetup.getLogger,
|
||||
},
|
||||
|
||||
authc: { getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request) },
|
||||
|
||||
authz: {
|
||||
actions: this.authorizationSetup.actions,
|
||||
checkPrivilegesWithRequest: this.authorizationSetup.checkPrivilegesWithRequest,
|
||||
|
@ -333,8 +336,12 @@ export class SecurityPlugin
|
|||
this.authorizationSetup.checkSavedObjectsPrivilegesWithRequest,
|
||||
mode: this.authorizationSetup.mode,
|
||||
},
|
||||
|
||||
license,
|
||||
privilegeDeprecationsService: getPrivilegeDeprecationsService(
|
||||
this.authorizationSetup,
|
||||
license,
|
||||
this.logger.get('deprecations')
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -5,5 +5,5 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { ElasticsearchRole, transformElasticsearchRoleToRole } from './elasticsearch_role';
|
||||
export { ElasticsearchRole, transformElasticsearchRoleToRole } from '../../../../authorization';
|
||||
export { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './put_payload';
|
||||
|
|
|
@ -10,10 +10,10 @@ import _ from 'lodash';
|
|||
import type { TypeOf } from '@kbn/config-schema';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { ElasticsearchRole } from '.';
|
||||
import { GLOBAL_RESOURCE } from '../../../../../common/constants';
|
||||
import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer';
|
||||
import { ResourceSerializer } from '../../../../authorization/resource_serializer';
|
||||
import type { ElasticsearchRole } from './elasticsearch_role';
|
||||
|
||||
/**
|
||||
* Elasticsearch specific portion of the role definition.
|
||||
|
|
Loading…
Reference in a new issue