Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
80f8fdd
patch api
baileycash-elastic Nov 4, 2025
6d8d7b3
add tags, update missing script ops message
baileycash-elastic Nov 5, 2025
feb3d11
remove replace tags functionality
baileycash-elastic Nov 6, 2025
8f1d7e1
add docs
baileycash-elastic Nov 6, 2025
cc90ddf
Merge branch 'main' into alerting-240356-patch
elasticmachine Nov 7, 2025
5d4a5df
tweak tests, trim inputs
baileycash-elastic Nov 7, 2025
7b2bb5d
remove found filter for greater transparency
baileycash-elastic Nov 7, 2025
4be7bdf
Merge branch 'main' into alerting-240356-patch
baileycash-elastic Nov 10, 2025
f0f9796
Merge branch 'main' into alerting-240356-patch
cnasikas Nov 12, 2025
2263196
Renames
cnasikas Nov 12, 2025
d6d5d94
Housekeeping
cnasikas Nov 12, 2025
32fca05
Authorize alert IDs by query
cnasikas Nov 13, 2025
fec5331
Update tags by query
cnasikas Nov 17, 2025
a8c4ae3
Merge branch 'main' into alerting-240356-patch
cnasikas Nov 17, 2025
25d7d4a
Fix types
cnasikas Nov 18, 2025
8977d65
Fixes, tests, and nitpicking
cnasikas Nov 18, 2025
fffeec3
Update and rename patchtagoptions.md to bulk_update_tag_options.md
baileycash-elastic Nov 18, 2025
ed2a6f7
Update docs
cnasikas Nov 19, 2025
f06f35b
Merge branch 'main' into alerting-240356-patch
cnasikas Nov 19, 2025
9292864
PR feedback
cnasikas Nov 19, 2025
11de026
Changes from node scripts/lint_ts_projects --fix
kibanamachine Nov 19, 2025
2d018bb
Merge branch 'main' into alerting-240356-patch
elasticmachine Nov 20, 2025
a10842d
Merge branch 'main' into alerting-240356-patch
cnasikas Nov 21, 2025
79f2b6b
Fix integration tests
cnasikas Nov 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type AlertingAuthorizationMock = jest.Mocked<Schema>;
const createAlertingAuthorizationMock = () => {
const mocked: AlertingAuthorizationMock = {
ensureAuthorized: jest.fn(),
bulkEnsureAuthorized: jest.fn(),
getAuthorizedRuleTypes: jest.fn().mockResolvedValue(new Map()),
getFindAuthorizationFilter: jest.fn().mockResolvedValue({
filter: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,13 +439,53 @@ describe('AlertingAuthorization', () => {
});
});

/**
* ensureAuthorized calls bulkEnsureAuthorized internally, so we just need
* to test that the parameters are passed correctly
*/
describe('ensureAuthorized', () => {
beforeEach(() => {
jest.clearAllMocks();
allRegisteredConsumers.clear();
allRegisteredConsumers.add('myApp');
});

it('authorized correctly', async () => {
const alertAuthorization = new AlertingAuthorization({
request,
ruleTypeRegistry,
authorization: securityStart.authz,
getSpaceId,
allRegisteredConsumers,
ruleTypesConsumersMap,
});

await alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['myApp'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
});

expect(checkPrivileges).toBeCalledTimes(1);
expect(checkPrivileges.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"kibana": Array [
"myType/myApp/rule/create",
],
},
]
`);
});
});

describe('bulkEnsureAuthorized', () => {
beforeEach(() => {
jest.clearAllMocks();
allRegisteredConsumers.clear();
allRegisteredConsumers.add('myApp');
});

it('is a no-op when there is no authorization api', async () => {
const alertAuthorization = new AlertingAuthorization({
request,
Expand All @@ -455,9 +495,8 @@ describe('AlertingAuthorization', () => {
ruleTypesConsumersMap,
});

await alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'myApp',
await alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['myApp'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
});
Expand All @@ -477,9 +516,8 @@ describe('AlertingAuthorization', () => {
ruleTypesConsumersMap,
});

await alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'myApp',
await alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['myApp'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
});
Expand All @@ -497,9 +535,8 @@ describe('AlertingAuthorization', () => {
ruleTypesConsumersMap,
});

await alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'myApp',
await alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['myApp'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
});
Expand All @@ -516,6 +553,44 @@ describe('AlertingAuthorization', () => {
`);
});

it('authorized correctly with multiple rule types and consumers', async () => {
allRegisteredConsumers.add('consumer-1');
allRegisteredConsumers.add('consumer-2');
allRegisteredConsumers.add('consumer-3');

const alertAuthorization = new AlertingAuthorization({
request,
ruleTypeRegistry,
authorization: securityStart.authz,
getSpaceId,
allRegisteredConsumers,
ruleTypesConsumersMap,
});

await alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [
{ ruleTypeId: 'rule-type-id-1', consumers: ['consumer-1', 'consumer-2'] },
{ ruleTypeId: 'rule-type-id-2', consumers: ['consumer-1', 'consumer-3'] },
],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
});

expect(checkPrivileges).toBeCalledTimes(1);
expect(checkPrivileges.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"kibana": Array [
"rule-type-id-1/consumer-1/rule/create",
"rule-type-id-1/consumer-2/rule/create",
"rule-type-id-2/consumer-1/rule/create",
"rule-type-id-2/consumer-3/rule/create",
],
},
]
`);
});

it('throws if user lacks the required rule privileges for the consumer', async () => {
securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue(
jest.fn(async () => ({ hasAllRequested: false }))
Expand All @@ -531,9 +606,8 @@ describe('AlertingAuthorization', () => {
});

await expect(
alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'myApp',
alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['myApp'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
})
Expand All @@ -557,9 +631,8 @@ describe('AlertingAuthorization', () => {
});

await expect(
alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'myApp',
alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['myApp'] }],
operation: WriteOperations.Update,
entity: AlertingAuthorizationEntity.Alert,
})
Expand All @@ -579,9 +652,8 @@ describe('AlertingAuthorization', () => {
});

await expect(
alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'not-exist',
alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['not-exist'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
})
Expand All @@ -600,9 +672,8 @@ describe('AlertingAuthorization', () => {
});

await expect(
alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'not-exist',
alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['not-exist'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
})
Expand All @@ -623,9 +694,8 @@ describe('AlertingAuthorization', () => {
});

await expect(
alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'not-exist',
alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['not-exist'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
})
Expand Down Expand Up @@ -660,9 +730,10 @@ describe('AlertingAuthorization', () => {
});

await expect(
auth.ensureAuthorized({
ruleTypeId: 'rule-type-1',
consumer: 'disabled-feature-consumer',
auth.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [
{ ruleTypeId: 'rule-type-1', consumers: ['disabled-feature-consumer'] },
],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
})
Expand All @@ -681,9 +752,8 @@ describe('AlertingAuthorization', () => {
ruleTypesConsumersMap,
});

await alertAuthorization.ensureAuthorized({
ruleTypeId: 'myType',
consumer: 'myApp',
await alertAuthorization.bulkEnsureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId: 'myType', consumers: ['myApp'] }],
operation: WriteOperations.Create,
entity: AlertingAuthorizationEntity.Rule,
additionalPrivileges: ['test/create'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export interface EnsureAuthorizedOpts {
additionalPrivileges?: string[];
}

export interface BulkEnsureAuthorizedOpts {
ruleTypeIdConsumersPairs: Array<{ ruleTypeId: string; consumers: string[] }>;
operation: ReadOperations | WriteOperations;
entity: AlertingAuthorizationEntity;
additionalPrivileges?: string[];
}

interface HasPrivileges {
read: boolean;
all: boolean;
Expand Down Expand Up @@ -236,35 +243,71 @@ export class AlertingAuthorization {
entity,
additionalPrivileges = [],
}: EnsureAuthorizedOpts) {
return this._ensureAuthorized({
ruleTypeIdConsumersPairs: [{ ruleTypeId, consumers: [consumer] }],
operation,
entity,
additionalPrivileges,
});
}

public async bulkEnsureAuthorized({
ruleTypeIdConsumersPairs,
operation,
entity,
additionalPrivileges = [],
}: BulkEnsureAuthorizedOpts) {
return this._ensureAuthorized({
ruleTypeIdConsumersPairs,
operation,
entity,
additionalPrivileges,
});
}

private async _ensureAuthorized({
ruleTypeIdConsumersPairs,
operation,
entity,
additionalPrivileges = [],
}: BulkEnsureAuthorizedOpts) {
const { authorization } = this;

const isAvailableConsumer = this.allRegisteredConsumers.has(consumer);
const areAllConsumersAvailable = ruleTypeIdConsumersPairs.every(({ consumers }) =>
consumers.every((consumer) => this.allRegisteredConsumers.has(consumer))
);

if (authorization && this.shouldCheckAuthorization()) {
const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request);

const { hasAllRequested } = await checkPrivileges({
kibana: [
authorization.actions.alerting.get(ruleTypeId, consumer, entity, operation),
...additionalPrivileges,
],
const privileges = ruleTypeIdConsumersPairs.flatMap(({ ruleTypeId, consumers }) =>
consumers.map((consumer) =>
authorization.actions.alerting.get(ruleTypeId, consumer, entity, operation)
)
);

const res = await checkPrivileges({
kibana: [...privileges, ...additionalPrivileges],
});

if (!isAvailableConsumer) {
const { hasAllRequested } = res;

if (!areAllConsumersAvailable) {
/**
* Under most circumstances this would have been caught by `checkPrivileges` as
* a user can't have Privileges to an unknown consumer, but super users
* don't actually get "privilege checked" so the made up consumer *will* return
* as Privileged.
* This check will ensure we don't accidentally let these through
*/
throw Boom.forbidden(getUnauthorizedMessage(ruleTypeId, consumer, operation, entity));
throw Boom.forbidden(getUnauthorizedMessage(ruleTypeIdConsumersPairs, operation, entity));
}

if (!hasAllRequested) {
throw Boom.forbidden(getUnauthorizedMessage(ruleTypeId, consumer, operation, entity));
throw Boom.forbidden(getUnauthorizedMessage(ruleTypeIdConsumersPairs, operation, entity));
}
} else if (!isAvailableConsumer) {
throw Boom.forbidden(getUnauthorizedMessage(ruleTypeId, consumer, operation, entity));
} else if (!areAllConsumersAvailable) {
throw Boom.forbidden(getUnauthorizedMessage(ruleTypeIdConsumersPairs, operation, entity));
}
}

Expand Down Expand Up @@ -316,7 +359,11 @@ export class AlertingAuthorization {
ensureRuleTypeIsAuthorized: (ruleTypeId: string, consumer: string, authType: string) => {
if (!authorizedRuleTypes.has(ruleTypeId) || authType !== params.authorizationEntity) {
throw Boom.forbidden(
getUnauthorizedMessage(ruleTypeId, consumer, params.operation, authType)
getUnauthorizedMessage(
[{ ruleTypeId, consumers: [consumer] }],
params.operation,
authType
)
);
}

Expand All @@ -326,8 +373,7 @@ export class AlertingAuthorization {
if (!authorizedConsumers[consumer]) {
throw Boom.forbidden(
getUnauthorizedMessage(
ruleTypeId,
consumer,
[{ ruleTypeId, consumers: [consumer] }],
params.operation,
params.authorizationEntity
)
Expand Down Expand Up @@ -496,10 +542,15 @@ function getConsumersWithPrivileges(
}

function getUnauthorizedMessage(
ruleTypeId: string,
scope: string,
ruleTypeIdConsumersPairs: BulkEnsureAuthorizedOpts['ruleTypeIdConsumersPairs'],
operation: string,
entity: string
): string {
return `Unauthorized by "${scope}" to ${operation} "${ruleTypeId}" ${entity}`;
const allConsumers = ruleTypeIdConsumersPairs.flatMap(({ consumers }) => consumers);
const allRuleTypeIds = ruleTypeIdConsumersPairs.map(({ ruleTypeId }) => ruleTypeId);

const ruleTypeIdsMessage = allRuleTypeIds.length <= 0 ? 'any' : `${allRuleTypeIds.join(', ')}`;
const consumersMessage = allConsumers.length <= 0 ? 'any consumer' : `${allConsumers.join(', ')}`;

return `Unauthorized by "${consumersMessage}" to ${operation} "${ruleTypeIdsMessage}" ${entity}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ Alerts as data client API Interface
- [BulkUpdateOptions](interfaces/bulkupdateoptions.md)
- [ConstructorOptions](interfaces/constructoroptions.md)
- [UpdateOptions](interfaces/updateoptions.md)
- [BulkUpdateTagArgs](interfaces/bulk_update_tag_args.md)
Loading