kibana/x-pack/plugins/features/server/feature_schema.ts
Jonathan Buttner b6c982c3b0
[Cases] RBAC (#95058)
* Adding feature flag for auth

* Hiding SOs and adding consumer field

* First pass at adding security changes

* Consumer as the app's plugin ID

* Create addConsumerToSO migration helper

* Fix mapping's SO consumer

* Add test for CasesActions

* Declare hidden types on SO client

* Restructure integration tests

* Init spaces_only integration tests

* Implementing the cases security string

* Adding security plugin tests for cases

* Rough concept for authorization class

* Adding comments

* Fix merge

* Get requiredPrivileges for classes

* Check privillages

* Ensure that all classes are available

* Success if hasAllRequested is true

* Failure if hasAllRequested is false

* Adding schema updates for feature plugin

* Seperate basic from trial

* Enable SIR on integration tests

* Starting the plumbing for authorization in plugin

* Unit tests working

* Move find route logic to case client

* Create integration test helper functions

* Adding auth to create call

* Create getClassFilter helper

* Add class attribute to find request

* Create getFindAuthorizationFilter

* Ensure savedObject is authorized in find method

* Include fields for authorization

* Combine authorization filter with cases & subcases filter

* Fix isAuthorized flag

* Fix merge issue

* Create/delete spaces & users before and after tests

* Add more user and roles

* [Cases] Convert filters from strings to KueryNode (#95288)

* [Cases] RBAC: Rename class to scope (#95535)

* [Cases][RBAC] Rename scope to owner (#96035)

* [Cases] RBAC: Create & Find integration tests (#95511)

* [Cases] Cases client enchantment (#95923)

* [Cases] Authorization and Client Audit Logger (#95477)

* Starting audit logger

* Finishing auth audit logger

* Fixing tests and types

* Adding audit event creator

* Renaming class to scope

* Adding audit logger messages to create and find

* Adding comments and fixing import issue

* Fixing type errors

* Fixing tests and adding username to message

* Addressing PR feedback

* Removing unneccessary log and generating id

* Fixing module issue and remove expect.anything

* [Cases] Migrate sub cases routes to a client (#96461)

* Adding sub cases client

* Move sub case routes to case client

* Throw when attempting to access the sub cases client

* Fixing throw and removing user ans soclients

* [Cases] RBAC: Migrate routes' unit tests to integration tests (#96374)

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>

* [Cases] Move remaining HTTP functionality to client (#96507)

* Moving deletes and find for attachments

* Moving rest of comment apis

* Migrating configuration routes to client

* Finished moving routes, starting utils refactor

* Refactoring utilites and fixing integration tests

* Addressing PR feedback

* Fixing mocks and types

* Fixing integration tests

* Renaming status_stats

* Fixing test type errors

* Adding plugins to kibana.json

* Adding cases to required plugin

* [Cases] Refactoring authorization (#97483)

* Refactoring authorization

* Wrapping auth calls in helper for try catch

* Reverting name change

* Hardcoding the saved object types

* Switching ensure to owner array

* [Cases] Add authorization to configuration & cases routes (#97228)

* [Cases] Attachments RBAC (#97756)

* Starting rbac for comments

* Adding authorization to rest of comment apis

* Starting the comment rbac tests

* Fixing some of the rbac tests

* Adding some integration tests

* Starting patch tests

* Working tests for comments

* Working tests

* Fixing some tests

* Fixing type issues from pulling in master

* Fixing connector tests that only work in trial license

* Attempting to fix cypress

* Mock return of array for configure

* Fixing cypress test

* Cleaning up

* Addressing PR comments

* Reducing operations

* [Cases] Add RBAC to remaining Cases APIs (#98762)

* Starting rbac for comments

* Adding authorization to rest of comment apis

* Starting the comment rbac tests

* Fixing some of the rbac tests

* Adding some integration tests

* Starting patch tests

* Working tests for comments

* Working tests

* Fixing some tests

* Fixing type issues from pulling in master

* Fixing connector tests that only work in trial license

* Attempting to fix cypress

* Mock return of array for configure

* Fixing cypress test

* Cleaning up

* Working case update tests

* Addressing PR comments

* Reducing operations

* Working rbac push case tests

* Starting stats apis

* Working status tests

* User action tests and fixing migration errors

* Fixing type errors

* including error in message

* Addressing pr feedback

* Fixing some type errors

* [Cases] Add space only tests (#99409)

* Starting spaces tests

* Finishing space only tests

* Refactoring createCaseWithConnector

* Fixing spelling

* Addressing PR feedback and creating alert tests

* Fixing mocks

* [Cases] Add security only tests (#99679)

* Starting spaces tests

* Finishing space only tests

* Refactoring createCaseWithConnector

* Fixing spelling

* Addressing PR feedback and creating alert tests

* Fixing mocks

* Starting security only tests

* Adding remainder security only tests

* Using helper objects

* Fixing type error for null space

* Renaming utility variables

* Refactoring users and roles for security only tests

* Adding sub feature

* [Cases] Cleaning up the services and TODOs (#99723)

* Cleaning up the service intialization

* Fixing type errors

* Adding comments for the api

* Working test for cases client

* Fix type error

* Adding generated docs

* Adding more docs and cleaning up types

* Cleaning up readme

* More clean up and links

* Changing some file names

* Renaming docs

* Integration tests for cases privs and fixes (#100038)

* [Cases] RBAC on UI (#99478)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

* Fixing case ids by alert id route call

* [Cases] Fixing UI feature permissions and adding UI tests (#100074)

* Integration tests for cases privs and fixes

* Fixing ui cases permissions and adding tests

* Adding test for collection failure and fixing jest

* Renaming variables

* Fixing type error

* Adding some comments

* Validate cases features

* Fix new schema

* Adding owner param for the status stats

* Fix get case status tests

* Adjusting permissions text and fixing status

* Address PR feedback

* Adding top level feature back

* Fixing feature privileges

* Renaming

* Removing uneeded else

* Fixing tests and adding cases merge tests

* [Cases][Security Solution] Basic license security solution API tests (#100925)

* Cleaning up the fixture plugins

* Adding basic feature test

* renaming to unsecuredSavedObjectsClient (#101215)

* [Cases] RBAC Refactoring audit logging (#100952)

* Refactoring audit logging

* Adding unit tests for authorization classes

* Addressing feedback and adding util tests

* return undefined on empty array

* fixing eslint

* [Cases] Cleaning up RBAC integration tests (#101324)

* Adding tests for space permissions

* Adding tests for testing a disable feature

Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
2021-06-07 09:37:11 -04:00

503 lines
16 KiB
TypeScript

/*
* 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 { schema } from '@kbn/config-schema';
import { difference } from 'lodash';
import { Capabilities as UICapabilities } from '../../../../src/core/server';
import { KibanaFeatureConfig } from '../common';
import { FeatureKibanaPrivileges, ElasticsearchFeatureConfig } from '.';
// Each feature gets its own property on the UICapabilities object,
// but that object has a few built-in properties which should not be overwritten.
const prohibitedFeatureIds: Set<keyof UICapabilities> = new Set([
'catalogue',
'management',
'navLinks',
]);
const featurePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/;
const subFeaturePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/;
const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/;
const reservedFeaturePrrivilegePartRegex = /^(?!reserved_)[a-zA-Z0-9_-]+$/;
export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/;
const validLicenseSchema = schema.oneOf([
schema.literal('basic'),
schema.literal('standard'),
schema.literal('gold'),
schema.literal('platinum'),
schema.literal('enterprise'),
schema.literal('trial'),
]);
// sub-feature privileges are only available with a `gold` license or better, so restricting sub-feature privileges
// for `gold` or below doesn't make a whole lot of sense.
const validSubFeaturePrivilegeLicensesSchema = schema.oneOf([
schema.literal('platinum'),
schema.literal('enterprise'),
schema.literal('trial'),
]);
const listOfCapabilitiesSchema = schema.arrayOf(
schema.string({
validate(key: string) {
if (!uiCapabilitiesRegex.test(key)) {
return `Does not satisfy regexp ${uiCapabilitiesRegex.toString()}`;
}
},
})
);
const managementSchema = schema.recordOf(
schema.string({
validate(key: string) {
if (!managementSectionIdRegex.test(key)) {
return `Does not satisfy regexp ${managementSectionIdRegex.toString()}`;
}
},
}),
listOfCapabilitiesSchema
);
const catalogueSchema = listOfCapabilitiesSchema;
const alertingSchema = schema.arrayOf(schema.string());
const casesSchema = schema.arrayOf(schema.string());
const appCategorySchema = schema.object({
id: schema.string(),
label: schema.string(),
ariaLabel: schema.maybe(schema.string()),
euiIconType: schema.maybe(schema.string()),
order: schema.maybe(schema.number()),
});
const kibanaPrivilegeSchema = schema.object({
excludeFromBasePrivileges: schema.maybe(schema.boolean()),
management: schema.maybe(managementSchema),
catalogue: schema.maybe(catalogueSchema),
api: schema.maybe(schema.arrayOf(schema.string())),
app: schema.maybe(schema.arrayOf(schema.string())),
alerting: schema.maybe(
schema.object({
rule: schema.maybe(
schema.object({
all: schema.maybe(alertingSchema),
read: schema.maybe(alertingSchema),
})
),
alert: schema.maybe(
schema.object({
all: schema.maybe(alertingSchema),
read: schema.maybe(alertingSchema),
})
),
})
),
cases: schema.maybe(
schema.object({
all: schema.maybe(casesSchema),
read: schema.maybe(casesSchema),
})
),
savedObject: schema.object({
all: schema.arrayOf(schema.string()),
read: schema.arrayOf(schema.string()),
}),
ui: listOfCapabilitiesSchema,
});
const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({
id: schema.string({
validate(key: string) {
if (!subFeaturePrivilegePartRegex.test(key)) {
return `Does not satisfy regexp ${subFeaturePrivilegePartRegex.toString()}`;
}
},
}),
name: schema.string(),
includeIn: schema.oneOf([schema.literal('all'), schema.literal('read'), schema.literal('none')]),
minimumLicense: schema.maybe(validSubFeaturePrivilegeLicensesSchema),
management: schema.maybe(managementSchema),
catalogue: schema.maybe(catalogueSchema),
alerting: schema.maybe(
schema.object({
rule: schema.maybe(
schema.object({
all: schema.maybe(alertingSchema),
read: schema.maybe(alertingSchema),
})
),
alert: schema.maybe(
schema.object({
all: schema.maybe(alertingSchema),
read: schema.maybe(alertingSchema),
})
),
})
),
cases: schema.maybe(
schema.object({
all: schema.maybe(casesSchema),
read: schema.maybe(casesSchema),
})
),
api: schema.maybe(schema.arrayOf(schema.string())),
app: schema.maybe(schema.arrayOf(schema.string())),
savedObject: schema.object({
all: schema.arrayOf(schema.string()),
read: schema.arrayOf(schema.string()),
}),
ui: listOfCapabilitiesSchema,
});
const kibanaMutuallyExclusiveSubFeaturePrivilegeSchema = kibanaIndependentSubFeaturePrivilegeSchema.extends(
{
minimumLicense: schema.never(),
}
);
const kibanaSubFeatureSchema = schema.object({
name: schema.string(),
privilegeGroups: schema.maybe(
schema.arrayOf(
schema.oneOf([
schema.object({
groupType: schema.literal('mutually_exclusive'),
privileges: schema.maybe(
schema.arrayOf(kibanaMutuallyExclusiveSubFeaturePrivilegeSchema, { minSize: 1 })
),
}),
schema.object({
groupType: schema.literal('independent'),
privileges: schema.maybe(
schema.arrayOf(kibanaIndependentSubFeaturePrivilegeSchema, { minSize: 1 })
),
}),
])
)
),
});
const kibanaFeatureSchema = schema.object({
id: schema.string({
validate(value: string) {
if (!featurePrivilegePartRegex.test(value)) {
return `Does not satisfy regexp ${featurePrivilegePartRegex.toString()}`;
}
if (prohibitedFeatureIds.has(value)) {
return `[${value}] is not allowed`;
}
},
}),
name: schema.string(),
category: appCategorySchema,
order: schema.maybe(schema.number()),
excludeFromBasePrivileges: schema.maybe(schema.boolean()),
minimumLicense: schema.maybe(validLicenseSchema),
app: schema.arrayOf(schema.string()),
management: schema.maybe(managementSchema),
catalogue: schema.maybe(catalogueSchema),
alerting: schema.maybe(alertingSchema),
cases: schema.maybe(casesSchema),
privileges: schema.oneOf([
schema.literal(null),
schema.object({
all: schema.maybe(kibanaPrivilegeSchema),
read: schema.maybe(kibanaPrivilegeSchema),
}),
]),
subFeatures: schema.maybe(
schema.conditional(
schema.siblingRef('privileges'),
null,
// allows an empty array only
schema.arrayOf(schema.never(), { maxSize: 0 }),
schema.arrayOf(kibanaSubFeatureSchema)
)
),
privilegesTooltip: schema.maybe(schema.string()),
reserved: schema.maybe(
schema.object({
description: schema.string(),
privileges: schema.arrayOf(
schema.object({
id: schema.string({
validate(value: string) {
if (!reservedFeaturePrrivilegePartRegex.test(value)) {
return `Does not satisfy regexp ${reservedFeaturePrrivilegePartRegex.toString()}`;
}
},
}),
privilege: kibanaPrivilegeSchema,
})
),
})
),
});
const elasticsearchPrivilegeSchema = schema.object({
ui: schema.arrayOf(schema.string()),
requiredClusterPrivileges: schema.maybe(schema.arrayOf(schema.string())),
requiredIndexPrivileges: schema.maybe(
schema.recordOf(schema.string(), schema.arrayOf(schema.string()))
),
requiredRoles: schema.maybe(schema.arrayOf(schema.string())),
});
const elasticsearchFeatureSchema = schema.object({
id: schema.string({
validate(value: string) {
if (!featurePrivilegePartRegex.test(value)) {
return `Does not satisfy regexp ${featurePrivilegePartRegex.toString()}`;
}
if (prohibitedFeatureIds.has(value)) {
return `[${value}] is not allowed`;
}
},
}),
management: schema.maybe(managementSchema),
catalogue: schema.maybe(catalogueSchema),
privileges: schema.arrayOf(elasticsearchPrivilegeSchema),
});
export function validateKibanaFeature(feature: KibanaFeatureConfig) {
kibanaFeatureSchema.validate(feature);
// the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid.
const { app = [], management = {}, catalogue = [], alerting = [], cases = [] } = feature;
const unseenApps = new Set(app);
const managementSets = Object.entries(management).map((entry) => [
entry[0],
new Set(entry[1]),
]) as Array<[string, Set<string>]>;
const unseenManagement = new Map<string, Set<string>>(managementSets);
const unseenCatalogue = new Set(catalogue);
const unseenAlertTypes = new Set(alerting);
const unseenCasesTypes = new Set(cases);
function validateAppEntry(privilegeId: string, entry: readonly string[] = []) {
entry.forEach((privilegeApp) => unseenApps.delete(privilegeApp));
const unknownAppEntries = difference(entry, app);
if (unknownAppEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown app entries: ${unknownAppEntries.join(', ')}`
);
}
}
function validateCatalogueEntry(privilegeId: string, entry: readonly string[] = []) {
entry.forEach((privilegeCatalogue) => unseenCatalogue.delete(privilegeCatalogue));
const unknownCatalogueEntries = difference(entry || [], catalogue);
if (unknownCatalogueEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown catalogue entries: ${unknownCatalogueEntries.join(', ')}`
);
}
}
function validateAlertingEntry(privilegeId: string, entry: FeatureKibanaPrivileges['alerting']) {
const all: string[] = [...(entry?.rule?.all ?? []), ...(entry?.alert?.all ?? [])];
const read: string[] = [...(entry?.rule?.read ?? []), ...(entry?.alert?.read ?? [])];
all.forEach((privilegeAlertTypes) => unseenAlertTypes.delete(privilegeAlertTypes));
read.forEach((privilegeAlertTypes) => unseenAlertTypes.delete(privilegeAlertTypes));
const unknownAlertingEntries = difference([...all, ...read], alerting);
if (unknownAlertingEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown alerting entries: ${unknownAlertingEntries.join(', ')}`
);
}
}
function validateCasesEntry(privilegeId: string, entry: FeatureKibanaPrivileges['cases']) {
const all = entry?.all ?? [];
const read = entry?.read ?? [];
all.forEach((privilegeCasesTypes) => unseenCasesTypes.delete(privilegeCasesTypes));
read.forEach((privilegeCasesTypes) => unseenCasesTypes.delete(privilegeCasesTypes));
const unknownCasesEntries = difference([...all, ...read], cases);
if (unknownCasesEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown cases entries: ${unknownCasesEntries.join(', ')}`
);
}
}
function validateManagementEntry(
privilegeId: string,
managementEntry: Record<string, readonly string[]> = {}
) {
Object.entries(managementEntry).forEach(([managementSectionId, managementSectionEntry]) => {
if (unseenManagement.has(managementSectionId)) {
managementSectionEntry.forEach((entry) => {
unseenManagement.get(managementSectionId)!.delete(entry);
if (unseenManagement.get(managementSectionId)?.size === 0) {
unseenManagement.delete(managementSectionId);
}
});
}
if (!management[managementSectionId]) {
throw new Error(
`Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}`
);
}
const unknownSectionEntries = difference(
managementSectionEntry,
management[managementSectionId]
);
if (unknownSectionEntries.length > 0) {
throw new Error(
`Feature privilege ${
feature.id
}.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join(
', '
)}`
);
}
});
}
const privilegeEntries: Array<[string, FeatureKibanaPrivileges]> = [];
if (feature.privileges) {
privilegeEntries.push(...Object.entries(feature.privileges));
}
if (feature.reserved) {
feature.reserved.privileges.forEach((reservedPrivilege) => {
privilegeEntries.push([reservedPrivilege.id, reservedPrivilege.privilege]);
});
}
if (privilegeEntries.length === 0) {
return;
}
privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => {
if (!privilegeDefinition) {
throw new Error('Privilege definition may not be null or undefined');
}
validateAppEntry(privilegeId, privilegeDefinition.app);
validateCatalogueEntry(privilegeId, privilegeDefinition.catalogue);
validateManagementEntry(privilegeId, privilegeDefinition.management);
validateAlertingEntry(privilegeId, privilegeDefinition.alerting);
validateCasesEntry(privilegeId, privilegeDefinition.cases);
});
const subFeatureEntries = feature.subFeatures ?? [];
subFeatureEntries.forEach((subFeature) => {
subFeature.privilegeGroups.forEach((subFeaturePrivilegeGroup) => {
subFeaturePrivilegeGroup.privileges.forEach((subFeaturePrivilege) => {
validateAppEntry(subFeaturePrivilege.id, subFeaturePrivilege.app);
validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue);
validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management);
validateAlertingEntry(subFeaturePrivilege.id, subFeaturePrivilege.alerting);
validateCasesEntry(subFeaturePrivilege.id, subFeaturePrivilege.cases);
});
});
});
if (unseenApps.size > 0) {
throw new Error(
`Feature ${
feature.id
} specifies app entries which are not granted to any privileges: ${Array.from(
unseenApps.values()
).join(',')}`
);
}
if (unseenCatalogue.size > 0) {
throw new Error(
`Feature ${
feature.id
} specifies catalogue entries which are not granted to any privileges: ${Array.from(
unseenCatalogue.values()
).join(',')}`
);
}
if (unseenManagement.size > 0) {
const ungrantedManagement = Array.from(unseenManagement.entries()).reduce((acc, entry) => {
const values = Array.from(entry[1].values()).map(
(managementPage) => `${entry[0]}.${managementPage}`
);
return [...acc, ...values];
}, [] as string[]);
throw new Error(
`Feature ${
feature.id
} specifies management entries which are not granted to any privileges: ${ungrantedManagement.join(
','
)}`
);
}
if (unseenAlertTypes.size > 0) {
throw new Error(
`Feature ${
feature.id
} specifies alerting entries which are not granted to any privileges: ${Array.from(
unseenAlertTypes.values()
).join(',')}`
);
}
if (unseenCasesTypes.size > 0) {
throw new Error(
`Feature ${
feature.id
} specifies cases entries which are not granted to any privileges: ${Array.from(
unseenCasesTypes.values()
).join(',')}`
);
}
}
export function validateElasticsearchFeature(feature: ElasticsearchFeatureConfig) {
elasticsearchFeatureSchema.validate(feature);
// the following validation can't be enforced by the Joi schema without a very convoluted and verbose definition
const { privileges } = feature;
privileges.forEach((privilege, index) => {
const {
requiredClusterPrivileges = [],
requiredIndexPrivileges = [],
requiredRoles = [],
} = privilege;
if (
requiredClusterPrivileges.length === 0 &&
requiredIndexPrivileges.length === 0 &&
requiredRoles.length === 0
) {
throw new Error(
`Feature ${feature.id} has a privilege definition at index ${index} without any privileges defined.`
);
}
});
}