Compare commits

...

8 Commits

Author SHA1 Message Date
Gaurav Tewari
6593fabc4d fix(ai-assistant): apply the answer's time range when the explorer is already open
Explorer global time is Redux-only, so useDispatch uses the no-restricted-imports escape hatch.
redirectWithQueryBuilderData drops null params, so query and time land in one history entry.
2026-11-03 02:18:13 +05:30
Gaurav Tewari
dba5784f77 fix(ai-assistant): open explorer chips on the answer's time range 2026-11-03 01:51:06 +05:30
Gaurav Tewari
4080c64411 fix(ai-assistant): open trace queries in the Traces view 2026-11-03 01:10:24 +05:30
makeavish
2c590c6ceb test: assert buildExplorerNavigationUrl JSON-encodes panelTypes
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-03 01:10:12 +05:30
makeavish
5129467384 fix(ai-assistant): open apply_filter chips in the view their requestType implies
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-03 01:10:10 +05:30
Nikhil Mantri
a02ea41446 feat(authz): downtime schedule FGA (#13250)
#### Description

- Move `/api/v1/downtime_schedules` routes from role gates to
`CheckResources` with resource defs.
- Create and update check `rule:attach` on every id in `alertIds`.
Update diffs against the stored schedule: attach for added, detach for
removed.
- Migration 142 backfills planned-maintenance tuples plus attach and
detach on planned-maintenance and rule for admin and editor.
- Integration tests cover viewer, editor, admin, and a custom role
without rule attach or detach.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#289
Part of SigNoz/pulse-pod#355

#### Additional Information

Second of five stacked PRs splitting #12927. Stacked on #13249.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-11-02 18:29:40 +00:00
Aditya Singh
c63f818789 feat(traces-explorer): fetch all span fields in the list view (#13275)
#### Description

- The traces explorer list view no longer sends the selected columns as
`selectFields`. The API now returns every span field for each row. The
span details drawer in the next PR uses this data, so it can open
without another request.
- Adding, removing or reordering a column no longer refetches the list.
- The table reads attribute and resource columns from the `attributes`
and `resource` maps on each row. It uses `getSpanAttribute`, the same
lookup as trace details.
- `getSpanAttribute` now keeps `false` values. Before, boolean `false`
attributes showed "-" in the table and dropped out of the trace details
hover previews. Color-by now treats `0` and `false` as real values, not
`unknown`.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/195


#### Screenshots/Screen recording

No refetch on add/remove columns


https://github.com/user-attachments/assets/799b39b3-3594-441e-b5dc-0c8177c29afb

All fields fetched

<img width="1687" height="1000" alt="all fields fetched"
src="https://github.com/user-attachments/assets/af8dd374-6b30-40a5-92dc-caa07defc463"
/>



#### Additional Information

- Two backend gaps show "-" in some columns:
- `scope.name` and `scope.version`: the all-fields mode does not select
the `scope` column. Value suggestions for these fields are also empty.
The fix is to add `scope` to `ContextualSpanColumns`.
- Old camelCase column names (`durationNano`, `httpMethod`, ...) in
saved views and column preferences: the backend resolved them only when
they were in `selectFields`. The fix is a backend migration that renames
them. The workaround is to remove the column and add it again.
- Each row is larger, because the response carries all span fields.
- Export and add to dashboard still use the selected columns.
- The List view story rows now use the nested `resource` shape. The page
renders the same, so the existing story covers it.
- cc. @tewarig
2026-11-02 17:56:08 +00:00
Vikrant Gupta
6026e2372d feat(authz): support service and environment keys in telemetry selectors (#13036)
#### Description

- Telemetry grants can now scope on `resource.service.name` and
`resource.deployment.environment.name`, alongside
`signoz.workspace.key.id`.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#73
2026-11-02 16:28:30 +00:00
46 changed files with 2304 additions and 244 deletions

View File

@@ -15079,9 +15079,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:list
- tokenizer:
- VIEWER
- planned-maintenance:list
summary: List downtime schedules
tags:
- downtimeschedules
@@ -15136,9 +15136,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- tokenizer:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
summary: Create downtime schedule
tags:
- downtimeschedules
@@ -15183,9 +15187,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:delete
- tokenizer:
- EDITOR
- planned-maintenance:delete
summary: Delete downtime schedule
tags:
- downtimeschedules
@@ -15241,9 +15245,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:read
- tokenizer:
- VIEWER
- planned-maintenance:read
summary: Get downtime schedule by ID
tags:
- downtimeschedules
@@ -15298,9 +15302,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- tokenizer:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
summary: Update downtime schedule
tags:
- downtimeschedules

View File

@@ -3,7 +3,6 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -591,7 +590,6 @@ export const listViewInitialTraceQuery: Query = {
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},

View File

@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { matchRoute } from 'lib/router/matchRoute';
import { navigate } from 'lib/router/navigation';
@@ -26,6 +28,8 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { UpdateTimeInterval } from 'store/actions';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -47,6 +51,11 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -273,6 +282,7 @@ interface ApplyFilterDeps {
typeof useQueryBuilder
>['redirectWithQueryBuilderData'];
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
setCustomTimeRange: (start: number, end: number) => void;
}
/**
@@ -333,16 +343,33 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
);
return;
}
// Read before toUrlCompositeQuery, which drops the requestType envelope.
const panelType = getPanelTypeForRequestType(
requestTypeFromActionQuery(action.query),
action.signal,
);
const timeRange = getTimeRangeFromActionQuery(action.query);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
if (signalMatchesPathname(action.signal, deps.pathname)) {
// eslint-disable-next-line no-console
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
if (timeRange) {
deps.setCustomTimeRange(timeRange.start, timeRange.end);
}
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized);
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
// relativeTime outranks startTime/endTime in the time picker, so drop it.
[QueryParams.relativeTime]: null,
}),
});
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -353,8 +380,14 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
}),
});
navigate(url);
}
/** Picks the right rollback API call for a given action kind. */
@@ -387,6 +420,13 @@ export default function ActionsSection({
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
const dispatch = useDispatch();
const setCustomTimeRange = useCallback(
(start: number, end: number): void => {
dispatch(UpdateTimeInterval('custom', [start, end]));
},
[dispatch],
);
// Per-chip click state, keyed by chip key (see `key` below). Persists
// loading/success/error so the chip reflects the rollback outcome until
@@ -418,9 +458,16 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
});
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
}, [
actions,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
]);
if (actions.length === 0) {
return null;
@@ -559,6 +606,7 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
break;
}
@@ -613,6 +661,7 @@ export default function ActionsSection({
disabled={isSuccess}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>

View File

@@ -0,0 +1,213 @@
import {
ApplyFilterSignalDTO,
MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { UpdateTimeInterval } from 'store/actions';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('store/actions', () => {
const actual =
jest.requireActual<typeof import('store/actions')>('store/actions');
return { ...actual, UpdateTimeInterval: jest.fn(actual.UpdateTimeInterval) };
});
const mockUpdateTimeInterval = jest.mocked(UpdateTimeInterval);
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
interface ActionOverrides {
signal?: ApplyFilterSignalDTO;
start?: number;
end?: number;
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(
label: string,
{ signal = ApplyFilterSignalDTO.logs, start, end }: ActionOverrides = {},
): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
start,
end,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal,
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
},
};
}
function currentParam(param: string): string | null {
return new URLSearchParams(getCurrentLocation().search).get(param);
}
function renderOnLogsExplorer(action: MessageActionDTO): {
redirectWithQueryBuilderData: jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
} {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection actions={[action]} messageId="message-on-page" />,
undefined,
{
initialRoute: `${ROUTES.LOGS_EXPLORER}?${QueryParams.relativeTime}=6h`,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
return { redirectWithQueryBuilderData };
}
describe('ActionsSection apply_filter', () => {
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open in Logs Explorer')]}
messageId="message-1"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentParam(QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TABLE),
);
});
it.each([
[ApplyFilterSignalDTO.logs, ROUTES.LOGS_EXPLORER],
[ApplyFilterSignalDTO.traces, ROUTES.TRACES_EXPLORER],
[ApplyFilterSignalDTO.metrics, ROUTES.METRICS_EXPLORER_EXPLORER],
])(
'opens the %s explorer on the time range that produced the answer',
async (signal, route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[
countByServiceAction(`Open ${signal}`, {
signal,
start: START_MS,
end: END_MS,
}),
]}
messageId={`message-${signal}`}
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() => expect(getCurrentLocation().pathname).toBe(route));
expect(currentParam(QueryParams.startTime)).toBe(String(START_MS));
expect(currentParam(QueryParams.endTime)).toBe(String(END_MS));
},
);
it('still opens the explorer, without a time range, when the action has none', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open without time')]}
messageId="message-no-time"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentParam(QueryParams.compositeQuery)).not.toBeNull();
expect(currentParam(QueryParams.startTime)).toBeNull();
expect(currentParam(QueryParams.endTime)).toBeNull();
});
it('switches the open explorer to the Table view and the answer time range', async () => {
mockUpdateTimeInterval.mockClear();
const { redirectWithQueryBuilderData } = renderOnLogsExplorer(
countByServiceAction('Apply with time', { start: START_MS, end: END_MS }),
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{
[QueryParams.panelTypes]: PANEL_TYPES.TABLE,
[QueryParams.startTime]: START_MS,
[QueryParams.endTime]: END_MS,
[QueryParams.relativeTime]: null,
},
);
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('custom', [
START_MS,
END_MS,
]);
expect(mockUpdateTimeInterval.mock.invocationCallOrder[0]).toBeLessThan(
redirectWithQueryBuilderData.mock.invocationCallOrder[0],
);
// The redirect is the only navigation, so Back still restores the old entry.
expect(currentParam(QueryParams.relativeTime)).toBe('6h');
});
it('keeps the open explorer time when the action has no time range', async () => {
mockUpdateTimeInterval.mockClear();
const { redirectWithQueryBuilderData } = renderOnLogsExplorer(
countByServiceAction('Apply without time'),
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
expect(mockUpdateTimeInterval).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,106 @@
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from '../getPanelTypeForRequestType';
describe('getPanelTypeForRequestType', () => {
it.each([
[
Querybuildertypesv5RequestTypeDTO.scalar,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TABLE,
],
[
Querybuildertypesv5RequestTypeDTO.time_series,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TIME_SERIES,
],
[
Querybuildertypesv5RequestTypeDTO.raw,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.traces,
PANEL_TYPES.TRACE,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.metrics,
PANEL_TYPES.LIST,
],
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
});
it.each([
undefined,
null,
'',
Querybuildertypesv5RequestTypeDTO.raw_stream,
Querybuildertypesv5RequestTypeDTO.heatmap,
'distribution',
'nonsense',
'toString',
'constructor',
42,
{},
])(
'falls back to the List view for unmapped or invalid requestType (%p)',
(value) => {
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
PANEL_TYPES.LIST,
);
},
);
});
describe('requestTypeFromActionQuery', () => {
it('reads the requestType envelope field and returns undefined when absent', () => {
expect(
requestTypeFromActionQuery({
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: { queries: [] },
}),
).toBe('scalar');
expect(requestTypeFromActionQuery({})).toBeUndefined();
expect(requestTypeFromActionQuery(null)).toBeUndefined();
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
});
it('maps a grouped count() payload to the Table view', () => {
const query = {
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
};
expect(
getPanelTypeForRequestType(
requestTypeFromActionQuery(query),
ApplyFilterSignalDTO.logs,
),
).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -0,0 +1,38 @@
import { getTimeRangeFromActionQuery } from '../getTimeRangeFromActionQuery';
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
describe('getTimeRangeFromActionQuery', () => {
it('returns the absolute range from the query envelope', () => {
expect(
getTimeRangeFromActionQuery({ start: START_MS, end: END_MS }),
).toStrictEqual({ start: START_MS, end: END_MS });
});
it('normalizes nanosecond timestamps to milliseconds', () => {
expect(
getTimeRangeFromActionQuery({
start: START_MS * 1_000_000,
end: END_MS * 1_000_000,
}),
).toStrictEqual({ start: START_MS, end: END_MS });
});
it.each([
['a missing query', undefined],
['a null query', null],
['missing timestamps', {}],
['a missing end', { start: START_MS }],
['string timestamps', { start: String(START_MS), end: String(END_MS) }],
[
'non-finite timestamps',
{ start: Number.NaN, end: Number.POSITIVE_INFINITY },
],
['non-positive timestamps', { start: 0, end: -1 }],
['an empty range', { start: START_MS, end: START_MS }],
['an inverted range', { start: END_MS, end: START_MS }],
])('returns null for %s', (_, query) => {
expect(getTimeRangeFromActionQuery(query)).toBeNull();
});
});

View File

@@ -0,0 +1,30 @@
import {
ApplyFilterSignalDTO,
type MessageActionDTOQuery,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
export function getPanelTypeForRequestType(
requestType: unknown,
signal: ApplyFilterSignalDTO,
): PANEL_TYPES {
switch (requestType) {
case Querybuildertypesv5RequestTypeDTO.scalar:
return PANEL_TYPES.TABLE;
case Querybuildertypesv5RequestTypeDTO.time_series:
return PANEL_TYPES.TIME_SERIES;
case Querybuildertypesv5RequestTypeDTO.trace:
return signal === ApplyFilterSignalDTO.traces
? PANEL_TYPES.TRACE
: PANEL_TYPES.LIST;
default:
return PANEL_TYPES.LIST;
}
}
export function requestTypeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): unknown {
return query?.requestType;
}

View File

@@ -0,0 +1,25 @@
import type { MessageActionDTOQuery } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { normalizeTimeToMs } from 'utils/timeUtils';
export interface ActionTimeRange {
start: number;
end: number;
}
function toTimestampMs(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null;
}
return normalizeTimeToMs(Math.trunc(value));
}
export function getTimeRangeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): ActionTimeRange | null {
const start = toTimestampMs(query?.start);
const end = toTimestampMs(query?.end);
if (start === null || end === null || end <= start) {
return null;
}
return { start, end };
}

View File

@@ -1,9 +1,15 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import ListView from './index';
@@ -24,7 +30,7 @@ jest.mock('react-redux', () => {
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the query fires and the expected columns render, independent of that API.
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
@@ -35,6 +41,8 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
@@ -48,19 +56,19 @@ const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
@@ -68,6 +76,8 @@ const listRows = [
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
@@ -84,7 +94,9 @@ const mockSuccess = (rows: unknown[] = listRows): void => {
);
};
const renderListView = (): ReturnType<typeof render> =>
const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
@@ -98,8 +110,8 @@ const renderListView = (): ReturnType<typeof render> =>
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
@@ -118,6 +130,10 @@ describe('Traces ListView - Data Loaded', () => {
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
// attribute column reads from the attributes map
expect(screen.getByText('/api/cart')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
@@ -130,4 +146,37 @@ describe('Traces ListView - Data Loaded', () => {
// empty status fields render "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
});
it('requests every span field instead of the selected columns', async () => {
let requestBody: QueryRangeRequestV5 | undefined;
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
requestBody = await req.json<QueryRangeRequestV5>();
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const selectColumns = [{ name: 'http.route', fieldContext: 'attribute' }];
const { builder } = initialQueriesMap.traces;
renderListView({
...initialQueriesMap.traces,
builder: {
...builder,
queryData: [{ ...builder.queryData[0], selectColumns }],
queryTraceOperator: [
{
...initialQueryBuilderFormTraceOperatorValues,
expression: 'A',
selectColumns,
},
],
},
} as Query);
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
const queries = requestBody?.compositeQuery.queries ?? [];
expect(queries).toHaveLength(2);
queries.forEach(({ spec }) => {
expect(spec).not.toHaveProperty('selectFields');
});
});
});

View File

@@ -100,23 +100,20 @@ function ListView({
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
// Empty selectFields makes the API return every span field.
const requestQuery = useMemo(() => {
const query = getListViewQuery(
stagedQuery || initialQueriesMap.traces,
orderBy,
);
query.builder.queryData.forEach((queryData) => {
queryData.selectColumns = [];
});
query.builder.queryTraceOperator?.forEach((queryTraceOperator) => {
queryTraceOperator.selectColumns = [];
});
return query;
}, [stagedQuery, orderBy]);
const queryKey = useMemo(
() => [
@@ -127,14 +124,12 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -155,7 +150,6 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
// ENTITY_VERSION_V4,
@@ -164,10 +158,7 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);

View File

@@ -1,6 +1,7 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
@@ -16,7 +17,10 @@ export function getFieldColumn(
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
// Span rows nest attribute/resource fields in maps; trace rows are flat.
accessorFn: (row): unknown =>
row[name] ??
getSpanAttribute(row as Parameters<typeof getSpanAttribute>[0], name),
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,

View File

@@ -190,10 +190,12 @@ function SpanDetailsContent({
return null;
}
return {
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
clusterName: String(
getSpanAttribute(selectedSpan, 'k8s.cluster.name') ?? '',
),
podName: String(getSpanAttribute(selectedSpan, 'k8s.pod.name') ?? ''),
nodeName: String(getSpanAttribute(selectedSpan, 'k8s.node.name') ?? ''),
hostName: String(getSpanAttribute(selectedSpan, 'host.name') ?? ''),
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);

View File

@@ -88,5 +88,17 @@ describe('Presentation / Styling Utils', () => {
);
expect(value).toBe('host-1');
});
it('groups 0 and false under their own value, not "unknown"', () => {
expect(
getFlamegraphSpanGroupValue({ attributes: { 'host.name': 0 } }, HOST_FIELD),
).toBe('0');
expect(
getFlamegraphSpanGroupValue(
{ attributes: { 'host.name': false } },
HOST_FIELD,
),
).toBe('false');
});
});
});

View File

@@ -80,7 +80,7 @@ export function getFlamegraphRowMetrics(
export function getFlamegraphServiceName(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
): string {
return getSpanAttribute(span, 'service.name') || '';
return String(getSpanAttribute(span, 'service.name') ?? '');
}
/**
@@ -92,7 +92,7 @@ export function getFlamegraphSpanGroupValue(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
field: TelemetryFieldKey,
): string {
return getSpanAttribute(span, field.name) || 'unknown';
return String(getSpanAttribute(span, field.name) ?? 'unknown');
}
interface GetSpanColorArgs {

View File

@@ -27,6 +27,14 @@ describe('getAvailableColorByFieldNames', () => {
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('offers a field whose only values are 0 or false', () => {
const spans = [
span({ attributes: { 'host.name': 0 } }),
span({ attributes: { 'host.name': false } }),
];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('does not offer fields no span carries', () => {
const spans = [span({ resource: { 'service.name': 'api' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);

View File

@@ -9,14 +9,14 @@ import {
RESERVED_ERROR,
} from './utils/generateColorPair';
/** Like `||`, but keeps `0`. */
/** Like `||`, but keeps `0` and `false`. */
const valueOrUndefined = <T>(value: T): T | undefined =>
value || value === 0 ? value : undefined;
value || value === 0 || value === false ? value : undefined;
/**
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
* Falsy values count as missing, except `0`.
* Falsy values count as missing, except `0` and `false`.
*
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
* structurally — the only fields touched are `resource` and `attributes`.
@@ -30,7 +30,7 @@ export function getSpanAttribute(
tagMap?: Record<string, any>;
},
key: string,
): string | undefined {
): string | number | boolean | undefined {
return (
valueOrUndefined(span.resource?.[key]) ??
valueOrUndefined(span.attributes?.[key]) ??
@@ -117,7 +117,7 @@ export function getSpanGroupValue(
span: SpanV3,
colorByFieldName: string,
): string {
return getSpanAttribute(span, colorByFieldName) || 'unknown';
return String(getSpanAttribute(span, colorByFieldName) ?? 'unknown');
}
/**
@@ -143,6 +143,6 @@ export function resolveSpanColor(
*/
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
return COLOR_BY_OPTIONS.filter((opt) =>
spans.some((s) => getSpanAttribute(s, opt.field.name)),
spans.some((s) => getSpanAttribute(s, opt.field.name) !== undefined),
).map((opt) => opt.field.name);
}

View File

@@ -145,7 +145,7 @@ const timestampAt = (index: number, start: number, end: number): string => {
return new Date(end - index * step).toISOString();
};
export const traceSpanRows = ({
const flatSpanRows = ({
count,
offset,
limit,
@@ -173,9 +173,22 @@ export const traceSpanRows = ({
};
});
/** The List view returns every span field, with resource and attribute fields nested in maps. */
export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
flatSpanRows(options).map(
({ timestamp, data: { 'service.name': serviceName, ...data } }) => ({
timestamp,
data: {
...data,
attributes: {},
resource: { 'service.name': serviceName },
},
}),
);
/** The Trace view lists root spans, with the span count of the whole trace. */
export const traceRootSpanRows = (options: SpanRowsOptions): RawRow[] =>
traceSpanRows(options).map((row, index) => ({
flatSpanRows(options).map((row, index) => ({
...row,
data: {
...row.data,

View File

@@ -996,14 +996,19 @@ export function QueryBuilderProvider({
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>
Object.keys(searchParams).forEach((param) => {
// null removes the param instead of writing "null".
if (searchParams[param] === null) {
urlQuery.delete(param);
return;
}
urlQuery.set(
param,
shouldNotStringify
? (searchParams[param] as string)
: JSON.stringify(searchParams[param]),
),
);
);
});
}
// Remove Hidden Filters from URL query parameters on query change
urlQuery.delete(QueryParams.activeLogId);

View File

@@ -0,0 +1,58 @@
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getCurrentLocation } from 'lib/router/navigation';
import {
act,
AllTheProviders,
renderHook,
RenderHookResult,
waitFor,
} from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
jest.mock('hooks/useSafeNavigate', () => {
const { navigate } = jest.requireActual<
typeof import('lib/router/navigation')
>('lib/router/navigation');
return {
useSafeNavigate: (): unknown => ({
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
navigate(to, { replace: opts?.replace });
},
}),
};
});
const currentParams = (): URLSearchParams =>
new URLSearchParams(getCurrentLocation().search);
const renderOn = (
url: string,
): RenderHookResult<QueryBuilderContextType, unknown> =>
renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
),
});
describe('redirectWithQueryBuilderData search params', () => {
it('removes params passed as null and JSON-encodes the rest', async () => {
const { result } = renderOn(
`/unconstrained?${QueryParams.relativeTime}=6h&${QueryParams.viewName}=kept`,
);
act(() => {
result.current.redirectWithQueryBuilderData(initialQueriesMap.logs, {
[QueryParams.relativeTime]: null,
[QueryParams.startTime]: 1000,
});
});
await waitFor(() =>
expect(currentParams().get(QueryParams.startTime)).toBe('1000'),
);
expect(currentParams().has(QueryParams.relativeTime)).toBe(false);
expect(currentParams().get(QueryParams.viewName)).toBe('kept');
});
});

View File

@@ -1,6 +1,7 @@
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
@@ -19,4 +20,14 @@ describe('buildExplorerNavigationUrl', () => {
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
initialQueriesMap[DataSource.LOGS],
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
});
});

View File

@@ -56,13 +56,14 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
return plannedMaintenances, nil
}
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, orgID string, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
storableMaintenanceRule := new(alertmanagertypes.PlannedMaintenanceWithRules)
err := r.sqlstore.
BunDB().
NewSelect().
Model(storableMaintenanceRule).
Relation("Rules").
Where("org_id = ?", orgID).
Where("id = ?", id.StringValue()).
Scan(ctx)
if err != nil {
@@ -162,11 +163,12 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return pm, nil
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, orgID string, id valuer.UUID) error {
_, err := r.sqlstore.
BunDB().
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("org_id = ?", orgID).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
@@ -182,7 +184,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
return err
}
existing, err := r.GetPlannedMaintenanceByID(ctx, id)
existing, err := r.GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
if err != nil {
return err
}

View File

@@ -1,6 +1,7 @@
package sqlalertmanagerstore
import (
"encoding/json"
"path/filepath"
"testing"
"time"
@@ -8,11 +9,13 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -92,3 +95,67 @@ func TestListPlannedMaintenanceSkipsInvalid(t *testing.T) {
require.Len(t, list, 1)
assert.Equal(t, valid.ID, list[0].ID)
}
func TestPlannedMaintenanceScopedToOrg(t *testing.T) {
testCases := []struct {
name string
sameOrg bool
wantFound bool
}{
{name: "SameOrg_Found", sameOrg: true, wantFound: true},
{name: "OtherOrg_NotFound", sameOrg: false, wantFound: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
schedule := &alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
Name: "window",
Schedule: `{"timezone":"UTC","startTime":"2024-01-01T12:00:00Z","recurrence":{"duration":"2h","repeatType":"daily"}}`,
OrgID: valuer.GenerateUUID().StringValue(),
}
_, err := store.BunDB().NewInsert().Model(schedule).Exec(t.Context())
require.NoError(t, err)
orgID := valuer.GenerateUUID().StringValue()
if testCase.sameOrg {
orgID = schedule.OrgID
}
maintenanceStore := NewMaintenanceStore(store, factorytest.NewSettings())
got, err := maintenanceStore.GetPlannedMaintenanceByID(t.Context(), orgID, schedule.ID)
if testCase.wantFound {
require.NoError(t, err)
assert.Equal(t, schedule.ID, got.ID)
} else {
assert.True(t, errors.Ast(err, errors.TypeNotFound))
}
postable := &alertmanagertypes.PostablePlannedMaintenance{Name: "renamed", Schedule: new(alertmanagertypes.Schedule)}
require.NoError(t, json.Unmarshal([]byte(schedule.Schedule), postable.Schedule))
claimsCtx := authtypes.NewContextWithClaims(t.Context(), authtypes.Claims{OrgID: orgID, Email: "user@example.com"})
err = maintenanceStore.UpdatePlannedMaintenance(claimsCtx, postable, schedule.ID)
if testCase.wantFound {
require.NoError(t, err)
got, err = maintenanceStore.GetPlannedMaintenanceByID(t.Context(), orgID, schedule.ID)
require.NoError(t, err)
assert.Equal(t, "renamed", got.Name)
} else {
assert.True(t, errors.Ast(err, errors.TypeNotFound))
}
err = maintenanceStore.DeletePlannedMaintenance(t.Context(), orgID, schedule.ID)
require.NoError(t, err)
count, err := store.BunDB().NewSelect().Model(new(alertmanagertypes.StorablePlannedMaintenance)).Where("id = ?", schedule.ID.StringValue()).Count(t.Context())
require.NoError(t, err)
if testCase.wantFound {
assert.Equal(t, 0, count)
} else {
assert.Equal(t, 1, count)
}
})
}
}

View File

@@ -95,6 +95,7 @@ type provider struct {
prometheusHandler prometheus.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
rulerService ruler.Ruler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
@@ -142,6 +143,7 @@ func NewFactory(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -196,6 +198,7 @@ func NewFactory(
llmPricingRuleHandler,
traceDetailHandler,
rulerHandler,
rulerService,
statsHandler,
savedViewHandler,
globalConfig,
@@ -252,6 +255,7 @@ func newProvider(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -307,6 +311,7 @@ func newProvider(
prometheusHandler: prometheusHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
rulerService: rulerService,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,

View File

@@ -4,8 +4,11 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/gorilla/mux"
)
@@ -204,73 +207,166 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListDowntimeSchedules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetDowntimeScheduleByID), handler.OpenAPIDef{
ID: "GetDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Get downtime schedule by ID",
Description: "This endpoint returns a downtime schedule by ID",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Get downtime schedule by ID",
Description: "This endpoint returns a downtime schedule by ID",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateDowntimeSchedule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.DowntimeRuleIDsExtractor,
TargetSelector: ruler.DowntimeRuleSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.NewDowntimeRuleAttachExtractor(provider.rulerService),
TargetSelector: ruler.DowntimeRuleSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.NewDowntimeRuleDetachExtractor(provider.rulerService),
TargetSelector: ruler.DowntimeRuleSelector,
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -3,6 +3,7 @@ package querybuilder
import (
"context"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -14,7 +15,10 @@ import (
)
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
values := telemetrytypes.NewTelemetryGrantSelectors(id)
values, err := telemetrySelectorValues(id)
if err != nil {
return nil, err
}
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
@@ -182,27 +186,103 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
return nil, err
}
ids := make([]string, 0)
atoms := make([]WhereClauseCondition, 0)
for _, condition := range normalized.Conditions {
if !condition.TopLevel {
if !condition.TopLevel || len(condition.Values) == 0 {
continue
}
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
if !ok {
if condition.Operator != "=" && condition.Operator != "IN" {
continue
}
if condition.Operator == "=" || condition.Operator == "IN" {
for _, value := range condition.Values {
ids = append(ids, queryType+"/"+key+"/"+value)
}
if _, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key); !ok {
continue
}
atoms = append(atoms, condition)
}
if len(atoms) == 0 {
return []string{typeWildcard}, nil
}
if len(ids) == 0 {
return []string{typeWildcard}, nil
listIndex := -1
for index, atom := range atoms {
if atom.Operator != "IN" || len(atom.Values) < 2 {
continue
}
if listIndex >= 0 {
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
}
listIndex = index
}
if listIndex < 0 {
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
}
list := atoms[listIndex]
ids := make([]string, 0, len(list.Values))
for _, value := range list.Values {
atoms[listIndex] = WhereClauseCondition{Key: list.Key, Operator: "=", Values: []string{value}}
ids = append(ids, queryType+"/"+telemetryAtomsFilter(atoms))
}
return ids, nil
}
func telemetryAtomsFilter(atoms []WhereClauseCondition) string {
parts := make([]string, 0, len(atoms))
for _, atom := range atoms {
literals := make([]string, 0, len(atom.Values))
for _, value := range atom.Values {
literals = append(literals, FilterStringLiteral(value))
}
if len(literals) == 1 {
parts = append(parts, atom.Key+" = "+literals[0])
continue
}
parts = append(parts, atom.Key+" IN ("+strings.Join(literals, ", ")+")")
}
slices.Sort(parts)
return strings.Join(slices.Compact(parts), " AND ")
}
func telemetrySelectorValues(id string) ([]string, error) {
queryType, filter, found := strings.Cut(id, "/")
if !found || filter == coretypes.WildCardSelectorString {
return telemetrytypes.NewTelemetryGrantSelectors(id), nil
}
normalized, err := NormalizeWhereClause(filter, nil)
if err != nil {
return nil, err
}
values := make([]string, 0)
for _, condition := range normalized.Conditions {
if !condition.TopLevel {
continue
}
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
if !ok {
continue
}
switch {
case condition.Operator == "=", condition.Operator == "IN" && len(condition.Values) == 1:
values = append(values, queryType+"/"+key+"/"+condition.Values[0], queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
case condition.Operator == "IN":
values = append(values, queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
}
}
values = append(values, queryType+"/"+coretypes.WildCardSelectorString, coretypes.WildCardSelectorString)
seen := make(map[string]struct{}, len(values))
unique := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique, nil
}

View File

@@ -21,100 +21,172 @@ func TestQueryRangeResources(t *testing.T) {
expected []coretypes.ResourceWithID
}{
{
name: "top level key equality",
name: "TopLevelEquality_OneResource",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "resource prefixed key",
name: "ResourcePrefixedKey_KeptInID",
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/resource.signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "in atom requires every value",
name: "SingleList_OneResourcePerValue",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'b'"},
},
},
{
name: "multiple equality atoms each require a grant",
name: "SameKeyEqualities_OneResource",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a' AND signoz.workspace.key.id = 'b'"},
},
},
{
name: "no filter expression",
name: "DifferentKeyEqualities_OneResource",
body: builderQueryBody("logs", "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.deployment.environment.name = 'prod' AND resource.service.name = 'checkout'"},
},
},
{
name: "SingleListWithEquality_ExpandsPerValue",
body: builderQueryBody("logs", "resource.service.name IN ('b', 'a') AND signoz.workspace.key.id = 'k'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
},
},
{
name: "TwoLists_OneResource",
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"},
},
},
{
name: "SingleValueListBesideList_Pinned",
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('k')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
},
},
{
name: "SingleValueListsOnly_Pinned",
body: builderQueryBody("logs", "resource.service.name IN ('a') AND signoz.workspace.key.id IN ('k')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
},
},
{
name: "RepeatedList_ExpandsOnce",
body: builderQueryBody("logs", "resource.service.name IN ('checkout') AND resource.service.name IN ('checkout')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
},
},
{
name: "TwoListsOneKey_OneResource",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') AND resource.signoz.workspace.key.id IN ('b', 'c')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.signoz.workspace.key.id IN ('b', 'c') AND signoz.workspace.key.id IN ('a', 'b')"},
},
},
{
name: "OldEnvironmentSpelling_NotAnAtom",
body: builderQueryBody("traces", "resource.deployment.environment = 'prod'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"},
},
},
{
name: "ServiceList_OneResourcePerValue",
body: builderQueryBody("logs", "resource.service.name IN ('frontend', 'checkout')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'frontend'"},
},
},
{
name: "BareCustomerKey_NotAnAtom",
body: builderQueryBody("logs", "service.name = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "NoFilter_QueryTypeWildcard",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "key atom under or does not qualify",
name: "TopLevelOr_NoAtoms",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "negated key atom does not qualify",
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
name: "ListUnderOr_NoAtoms",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "key inequality does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
name: "NestedNegationUnion_NoAtoms",
body: builderQueryBody("logs", "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "value with spaces and slashes stays plaintext in the id",
name: "ValueWithSpacesAndSlashes_Plaintext",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'check out/2'"},
},
},
{
name: "audit source maps to audit logs resource",
name: "AuditSource_AuditLogsResource",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
},
},
{
name: "ai builder query maps to traces resource without a signal",
name: "AIBuilderQuery_TracesResource",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "ai builder query without filter is wildcard",
name: "AIBuilderQueryNoFilter_Wildcard",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
},
},
{
name: "promql is wildcard only",
name: "PromQL_WildcardOnly",
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "promql/*"},
},
},
{
name: "clickhouse sql covers all signals",
name: "ClickHouseSQL_AllSignals",
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "clickhouse_sql/*"},
@@ -124,29 +196,29 @@ func TestQueryRangeResources(t *testing.T) {
},
},
{
name: "formula produces no resources",
name: "Formula_NoResources",
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
expected: []coretypes.ResourceWithID{},
},
{
name: "trace operator rides on its referenced queries",
name: "TraceOperator_ReferencedQueries",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "variable substitution qualifies",
name: "VariableSubstitution_Qualifies",
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "duplicate queries dedupe",
name: "DuplicateQueries_Deduped",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
},
},
}
@@ -188,12 +260,28 @@ func TestTelemetrySelector(t *testing.T) {
return values
}
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a'"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/resource.signoz.workspace.key.id = 'a' AND status = 500"))
assert.Equal(t, []string{
"builder_query/resource.service.name/x",
"builder_query/resource.service.name/*",
"builder_query/signoz.workspace.key.id/z",
"builder_query/signoz.workspace.key.id/*",
"builder_query/*",
"*",
}, selectorValues("builder_query/resource.service.name = 'x' AND signoz.workspace.key.id = 'z'"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a', 'b')"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a')"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a' OR status = 500"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/service.name = 'x'"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a/b'"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
assert.Equal(t, []string{"*"}, selectorValues("*"))
// a value containing "/" stays one logical segment (SplitN 3).
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, "builder_query/signoz.workspace.key.id = ", orgID)
assert.Error(t, err)
}
func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
@@ -205,7 +293,7 @@ func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
{
name: "FilterPresent_ScopedID",
body: `{"start":1,"end":2,"filter":{"expression":"signoz.workspace.key.id = 'key-a'"}}`,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/key-a"}},
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'key-a'"}},
},
{
name: "FilterAbsent_Wildcard",

View File

@@ -2,6 +2,7 @@ package querybuilder
import (
"fmt"
"slices"
"sort"
"strconv"
"strings"
@@ -39,6 +40,7 @@ const (
type normalizedPart struct {
text string
join joinKind
negated bool
skipped bool
}
@@ -48,11 +50,12 @@ type normalizedValue struct {
}
type whereClauseNormalizer struct {
variables map[string]qbtypes.VariableItem
conditions []WhereClauseCondition
negated bool
orDepth int
errors []string
variables map[string]qbtypes.VariableItem
conditions []WhereClauseCondition
negated bool
orDepth int
negationDepth int
errors []string
}
func NormalizeWhereClause(expression string, variables map[string]qbtypes.VariableItem) (*NormalizedWhereClause, error) {
@@ -113,8 +116,11 @@ func NormalizeWhereClause(expression string, variables map[string]qbtypes.Variab
sort.Slice(visitor.conditions, func(i, j int) bool {
return visitor.conditions[i].sortKey() < visitor.conditions[j].sortKey()
})
conditions := slices.CompactFunc(visitor.conditions, func(left, right WhereClauseCondition) bool {
return left.sortKey() == right.sortKey()
})
return &NormalizedWhereClause{Expression: part.text, Conditions: visitor.conditions}, nil
return &NormalizedWhereClause{Expression: part.text, Conditions: conditions}, nil
}
func (condition WhereClauseCondition) sortKey() string {
@@ -197,20 +203,22 @@ func (visitor *whereClauseNormalizer) visitUnaryExpression(ctx grammar.IUnaryExp
negated := ctx.NOT() != nil
if negated {
visitor.negated = !visitor.negated
visitor.negationDepth++
}
part := visitor.visitPrimary(ctx.Primary())
if negated {
visitor.negated = !visitor.negated
visitor.negationDepth--
if part.skipped {
return part
}
if part.join != joinKindNone {
return normalizedPart{text: "NOT (" + part.text + ")", join: joinKindNone}
if part.join != joinKindNone || part.negated {
return normalizedPart{text: "NOT (" + part.text + ")", negated: true}
}
return normalizedPart{text: "NOT " + part.text, join: joinKindNone}
return normalizedPart{text: "NOT " + part.text, negated: true}
}
return part
@@ -439,7 +447,7 @@ func (visitor *whereClauseNormalizer) appendCondition(key, operator string, valu
Operator: operator,
Values: values,
Negated: visitor.negated,
TopLevel: visitor.orDepth == 0 && !visitor.negated,
TopLevel: visitor.orDepth == 0 && visitor.negationDepth == 0,
})
}

View File

@@ -25,6 +25,15 @@ func TestNormalizeWhereClauseEquivalenceClasses(t *testing.T) {
},
expected: "service.name = 'frontend' AND status = 200",
},
{
name: "nested not keeps its parentheses",
expressions: []string{
"NOT (NOT a = 1)",
"NOT(NOT a=1)",
"not (not a = 1)",
},
expected: "NOT (NOT a = 1)",
},
{
name: "operand order",
expressions: []string{
@@ -275,15 +284,25 @@ func TestNormalizeWhereClauseTopLevel(t *testing.T) {
expected: map[string]bool{"service.name": false, "status": true},
},
{
name: "double negation restores top level",
name: "any negation disqualifies even when it cancels",
expression: "NOT (NOT (service.name = 'a'))",
expected: map[string]bool{"service.name": true},
expected: map[string]bool{"service.name": false},
},
{
name: "in condition under and is top level",
expression: "service.name IN ('a', 'b') AND x = 1",
expected: map[string]bool{"service.name": true, "x": true},
},
{
name: "nested negation over an and is a disjunction",
expression: "NOT (NOT service.name = 'a' AND NOT status = 500)",
expected: map[string]bool{"service.name": false, "status": false},
},
{
name: "negation over an or is a conjunction of negations",
expression: "NOT (service.name = 'a' OR status = 500)",
expected: map[string]bool{"service.name": false, "status": false},
},
}
for _, testCase := range testCases {
@@ -327,6 +346,8 @@ func TestNormalizeWhereClauseIdempotence(t *testing.T) {
"a IN [3, 1, 2] AND hasAll(tags, ['a', 'b']) AND body CONTAINS 'x'",
"duration BETWEEN 1 AND 10 OR duration > 100",
`msg = 'with \'escapes\' and "quotes"'`,
"NOT (NOT resource.region = 'us-east')",
"NOT (NOT (a = 1 OR b = 2)) AND NOT c EXISTS",
}
for _, expression := range expressions {
@@ -439,3 +460,13 @@ func TestFilterStringLiteral(t *testing.T) {
})
}
}
func TestNormalizeWhereClauseDedupesRepeatedConditions(t *testing.T) {
normalized, err := NormalizeWhereClause("service.name IN ('checkout') AND service.name IN ('checkout') AND status = 500 AND status = 500", nil)
require.NoError(t, err)
assert.Equal(t, "service.name IN ('checkout') AND status = 500", normalized.Expression)
assert.Equal(t, []WhereClauseCondition{
{Key: "service.name", Operator: "IN", Values: []string{"checkout"}, TopLevel: true},
{Key: "status", Operator: "=", Values: []string{"500"}, TopLevel: true},
}, normalized.Conditions)
}

View File

@@ -17,6 +17,79 @@ const (
type diffSide int
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
if err != nil {
return nil, err
}
return defaultToWildcardIfEmpty(ids), nil
}}
var DowntimeRuleSelector coretypes.SelectorFunc = func(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
if id == coretypes.WildCardSelectorString {
return coretypes.WildcardSelector(ctx, resource, id, orgID)
}
return coretypes.IDSelector(ctx, resource, id, orgID)
}
func NewDowntimeRuleAttachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
return newDowntimeDiffExtractor(rulerService, attachedSide)
}
func NewDowntimeRuleDetachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
return newDowntimeDiffExtractor(rulerService, detachedSide)
}
func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
scheduleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
stored, err := rulerService.MaintenanceStore().GetPlannedMaintenanceByID(ctx, orgID.StringValue(), scheduleID)
if err != nil {
return nil, err
}
incoming, err := DowntimeRuleIDsExtractor.Fn(ec)
if err != nil {
return nil, err
}
attached, detached := diffDowntimeRuleLinks(stored.RuleIDs, incoming)
if side == detachedSide {
return detached, nil
}
return attached, nil
}}
}
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
return diffLinks(defaultToWildcardIfEmpty(stored), incoming)
}
func defaultToWildcardIfEmpty(ids []string) []string {
if len(ids) == 0 {
return []string{coretypes.WildCardSelectorString}
}
return ids
}
func NewCreateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {

View File

@@ -1,11 +1,30 @@
package ruler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type stubRuler struct {
Ruler
maintenanceStore alertmanagertypes.MaintenanceStore
}
func (stub stubRuler) MaintenanceStore() alertmanagertypes.MaintenanceStore {
return stub.maintenanceStore
}
func TestDiffLinks(t *testing.T) {
testCases := []struct {
name string
@@ -75,3 +94,125 @@ func TestDiffLinks(t *testing.T) {
})
}
}
func TestDiffDowntimeRuleLinks(t *testing.T) {
testCases := []struct {
name string
stored []string
incoming []string
wantAttached []string
wantDetached []string
}{
{
name: "AllToAll_NoChange",
stored: nil,
incoming: []string{"*"},
wantAttached: []string{},
wantDetached: []string{},
},
{
name: "NarrowFromAllToOne_DetachesAllAttachesOne",
stored: nil,
incoming: []string{"r1"},
wantAttached: []string{"r1"},
wantDetached: []string{"*"},
},
{
name: "WidenFromOneToAll_DetachesOneAttachesAll",
stored: []string{"r1"},
incoming: []string{"*"},
wantAttached: []string{"*"},
wantDetached: []string{"r1"},
},
{
name: "SwapRules_DiffsIDs",
stored: []string{"r1"},
incoming: []string{"r2"},
wantAttached: []string{"r2"},
wantDetached: []string{"r1"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attached, detached := diffDowntimeRuleLinks(testCase.stored, testCase.incoming)
assert.ElementsMatch(t, testCase.wantAttached, attached)
assert.ElementsMatch(t, testCase.wantDetached, detached)
})
}
}
func TestDowntimeRuleIDsExtractor(t *testing.T) {
testCases := []struct {
name string
body string
wantIDs []string
}{
{
name: "MissingAlertIds_AllRules",
body: `{"name": "window"}`,
wantIDs: []string{"*"},
},
{
name: "EmptyAlertIds_AllRules",
body: `{"alertIds": []}`,
wantIDs: []string{"*"},
},
{
name: "ExplicitAlertIds_Kept",
body: `{"alertIds": ["r1", "r2"]}`,
wantIDs: []string{"r1", "r2"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ids, err := DowntimeRuleIDsExtractor.Fn(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}
func TestDowntimeDiffExtractorScopesStoreByOrg(t *testing.T) {
orgID := valuer.GenerateUUID()
scheduleID := valuer.GenerateUUID()
testCases := []struct {
name string
side diffSide
hasClaims bool
wantIDs []string
wantErr bool
}{
{name: "AttachSide_UsesClaimsOrg", side: attachedSide, hasClaims: true, wantIDs: []string{"r2"}},
{name: "DetachSide_UsesClaimsOrg", side: detachedSide, hasClaims: true, wantIDs: []string{"r1"}},
{name: "NoClaims_Error", side: attachedSide, wantErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
store := alertmanagertypestest.NewMockMaintenanceStore(t)
if testCase.hasClaims {
store.EXPECT().GetPlannedMaintenanceByID(mock.Anything, orgID.StringValue(), scheduleID).Return(&alertmanagertypes.PlannedMaintenance{RuleIDs: []string{"r1"}}, nil)
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/downtime_schedules/"+scheduleID.StringValue(), nil)
req = mux.SetURLVars(req, map[string]string{"id": scheduleID.StringValue()})
if testCase.hasClaims {
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{OrgID: orgID.StringValue()}))
}
ids, err := newDowntimeDiffExtractor(stubRuler{maintenanceStore: store}, testCase.side).Fn(coretypes.ExtractorContext{Request: req, RequestBody: []byte(`{"alertIds": ["r2"]}`)})
if testCase.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}

View File

@@ -283,7 +283,13 @@ func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *htt
return
}
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, id)
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
if err != nil {
render.Error(rw, err)
return
@@ -356,7 +362,13 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
return
}
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, id)
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, claims.OrgID, id)
if err != nil {
render.Error(rw, err)
return

View File

@@ -105,6 +105,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ llmpricingrule.Handler }{},
struct{ tracedetail.Handler }{},
struct{ ruler.Handler }{},
struct{ ruler.Ruler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
global.Config{},

View File

@@ -48,6 +48,7 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/sharder/singlesharder"
@@ -268,6 +269,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDeleteOrphanSARolesFactory(),
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
sqlmigration.NewRenameSavedViewDeprecatedTraceFieldsFactory(),
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
)
}
@@ -332,7 +334,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
)
}
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager, rulerService ruler.Ruler) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
@@ -374,6 +376,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.LLMPricingRuleHandler,
handlers.TraceDetail,
handlers.RulerHandler,
rulerService,
handlers.StatsHandler,
handlers.SavedView,
globalConfig,

View File

@@ -107,6 +107,7 @@ func TestNewProviderFactories(t *testing.T) {
nil,
nil,
nil,
nil,
)
})
}

View File

@@ -622,7 +622,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager, rulerInstance),
"signoz",
)
if err != nil {

View File

@@ -0,0 +1,174 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addDowntimeTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddDowntimeTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_downtime_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDowntimeTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addDowntimeTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDowntimeTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
crudVerbs := []string{"create", "read", "update", "delete", "list"}
readVerbs := []string{"read", "list"}
linkVerbs := []string{"attach", "detach"}
tuples := make([]migrationTuple, 0, 20)
for _, verb := range crudVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "planned-maintenance", verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "planned-maintenance", verb},
)
}
for _, verb := range readVerbs {
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "planned-maintenance", verb})
}
for _, objectName := range []string{"planned-maintenance", "rule"} {
for _, verb := range linkVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", objectName, verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", objectName, verb},
)
}
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addDowntimeTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -18,10 +18,19 @@ func NewMockMaintenanceStore(t interface {
mock.TestingT
Cleanup(func())
}) *MockMaintenanceStore {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock := &MockMaintenanceStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
t.Cleanup(func() {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock.AssertExpectations(t)
})
return mock
}
@@ -75,7 +84,7 @@ type MockMaintenanceStore_CreatePlannedMaintenance_Call struct {
// CreatePlannedMaintenance is a helper method to define mock.On call
// - context1 context.Context
// - postablePlannedMaintenance *alertmanagertypes.PostablePlannedMaintenance
func (_e *MockMaintenanceStore_Expecter) CreatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
func (_e *MockMaintenanceStore_Expecter) CreatePlannedMaintenance(context1 any, postablePlannedMaintenance any) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
return &MockMaintenanceStore_CreatePlannedMaintenance_Call{Call: _e.mock.On("CreatePlannedMaintenance", context1, postablePlannedMaintenance)}
}
@@ -108,16 +117,16 @@ func (_c *MockMaintenanceStore_CreatePlannedMaintenance_Call) RunAndReturn(run f
}
// DeletePlannedMaintenance provides a mock function for the type MockMaintenanceStore
func (_mock *MockMaintenanceStore) DeletePlannedMaintenance(context1 context.Context, uUID valuer.UUID) error {
ret := _mock.Called(context1, uUID)
func (_mock *MockMaintenanceStore) DeletePlannedMaintenance(context1 context.Context, s string, uUID valuer.UUID) error {
ret := _mock.Called(context1, s, uUID)
if len(ret) == 0 {
panic("no return value specified for DeletePlannedMaintenance")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
r0 = returnFunc(context1, uUID)
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) error); ok {
r0 = returnFunc(context1, s, uUID)
} else {
r0 = ret.Error(0)
}
@@ -131,24 +140,30 @@ type MockMaintenanceStore_DeletePlannedMaintenance_Call struct {
// DeletePlannedMaintenance is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - uUID valuer.UUID
func (_e *MockMaintenanceStore_Expecter) DeletePlannedMaintenance(context1 interface{}, uUID interface{}) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
return &MockMaintenanceStore_DeletePlannedMaintenance_Call{Call: _e.mock.On("DeletePlannedMaintenance", context1, uUID)}
func (_e *MockMaintenanceStore_Expecter) DeletePlannedMaintenance(context1 any, s any, uUID any) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
return &MockMaintenanceStore_DeletePlannedMaintenance_Call{Call: _e.mock.On("DeletePlannedMaintenance", context1, s, uUID)}
}
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 valuer.UUID
var arg1 string
if args[1] != nil {
arg1 = args[1].(valuer.UUID)
arg1 = args[1].(string)
}
var arg2 valuer.UUID
if args[2] != nil {
arg2 = args[2].(valuer.UUID)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
@@ -159,14 +174,14 @@ func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Return(err error)
return _c
}
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) error) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) error) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
_c.Call.Return(run)
return _c
}
// GetPlannedMaintenanceByID provides a mock function for the type MockMaintenanceStore
func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
ret := _mock.Called(context1, uUID)
func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Context, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
ret := _mock.Called(context1, s, uUID)
if len(ret) == 0 {
panic("no return value specified for GetPlannedMaintenanceByID")
@@ -174,18 +189,18 @@ func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Co
var r0 *alertmanagertypes.PlannedMaintenance
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)); ok {
return returnFunc(context1, uUID)
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)); ok {
return returnFunc(context1, s, uUID)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
r0 = returnFunc(context1, uUID)
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
r0 = returnFunc(context1, s, uUID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.PlannedMaintenance)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, valuer.UUID) error); ok {
r1 = returnFunc(context1, uUID)
if returnFunc, ok := ret.Get(1).(func(context.Context, string, valuer.UUID) error); ok {
r1 = returnFunc(context1, s, uUID)
} else {
r1 = ret.Error(1)
}
@@ -199,24 +214,30 @@ type MockMaintenanceStore_GetPlannedMaintenanceByID_Call struct {
// GetPlannedMaintenanceByID is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - uUID valuer.UUID
func (_e *MockMaintenanceStore_Expecter) GetPlannedMaintenanceByID(context1 interface{}, uUID interface{}) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
return &MockMaintenanceStore_GetPlannedMaintenanceByID_Call{Call: _e.mock.On("GetPlannedMaintenanceByID", context1, uUID)}
func (_e *MockMaintenanceStore_Expecter) GetPlannedMaintenanceByID(context1 any, s any, uUID any) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
return &MockMaintenanceStore_GetPlannedMaintenanceByID_Call{Call: _e.mock.On("GetPlannedMaintenanceByID", context1, s, uUID)}
}
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 valuer.UUID
var arg1 string
if args[1] != nil {
arg1 = args[1].(valuer.UUID)
arg1 = args[1].(string)
}
var arg2 valuer.UUID
if args[2] != nil {
arg2 = args[2].(valuer.UUID)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
@@ -227,7 +248,7 @@ func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Return(plannedMai
return _c
}
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
_c.Call.Return(run)
return _c
}
@@ -268,7 +289,7 @@ type MockMaintenanceStore_ListPlannedMaintenance_Call struct {
// ListPlannedMaintenance is a helper method to define mock.On call
// - context1 context.Context
// - s string
func (_e *MockMaintenanceStore_Expecter) ListPlannedMaintenance(context1 interface{}, s interface{}) *MockMaintenanceStore_ListPlannedMaintenance_Call {
func (_e *MockMaintenanceStore_Expecter) ListPlannedMaintenance(context1 any, s any) *MockMaintenanceStore_ListPlannedMaintenance_Call {
return &MockMaintenanceStore_ListPlannedMaintenance_Call{Call: _e.mock.On("ListPlannedMaintenance", context1, s)}
}
@@ -326,7 +347,7 @@ type MockMaintenanceStore_UpdatePlannedMaintenance_Call struct {
// - context1 context.Context
// - postablePlannedMaintenance *alertmanagertypes.PostablePlannedMaintenance
// - uUID valuer.UUID
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}, uUID interface{}) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 any, postablePlannedMaintenance any, uUID any) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
return &MockMaintenanceStore_UpdatePlannedMaintenance_Call{Call: _e.mock.On("UpdatePlannedMaintenance", context1, postablePlannedMaintenance, uUID)}
}

View File

@@ -403,8 +403,8 @@ type ListPlannedMaintenanceParams struct {
type MaintenanceStore interface {
CreatePlannedMaintenance(context.Context, *PostablePlannedMaintenance) (*PlannedMaintenance, error)
DeletePlannedMaintenance(context.Context, valuer.UUID) error
GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*PlannedMaintenance, error)
DeletePlannedMaintenance(context.Context, string, valuer.UUID) error
GetPlannedMaintenanceByID(context.Context, string, valuer.UUID) (*PlannedMaintenance, error)
UpdatePlannedMaintenance(context.Context, *PostablePlannedMaintenance, valuer.UUID) error
ListPlannedMaintenance(context.Context, string) ([]*PlannedMaintenance, error)
}

View File

@@ -134,12 +134,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
// rule — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
// saved-view — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
@@ -245,12 +249,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
// rule — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
// saved-view — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},

View File

@@ -1,6 +1,7 @@
package telemetrytypes
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -16,8 +17,12 @@ var telemetryGrantQueryTypes = map[string]bool{
"clickhouse_sql": false,
}
var telemetryGrantKeys = map[string]struct{}{
"signoz.workspace.key.id": {},
// signoz.workspace.key.id is stamped on the resource by ingestion and never arrives as an
// attribute, so its bare spelling is unambiguous; customer-controlled keys must say resource.
var telemetryGrantKeys = map[string]string{
"signoz.workspace.key.id": "signoz.workspace.key.id",
"service.name": "resource.service.name",
"deployment.environment.name": "resource.deployment.environment.name",
}
func NewTelemetryGrantKey(keyText string) (string, bool) {
@@ -26,11 +31,16 @@ func NewTelemetryGrantKey(keyText string) (string, bool) {
return "", false
}
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
canonical, ok := telemetryGrantKeys[fieldKey.Name]
if !ok {
return "", false
}
return fieldKey.Name, true
if fieldKey.FieldContext == FieldContextUnspecified && canonical != fieldKey.Name {
return "", false
}
return canonical, true
}
func NewTelemetryGrantSelector(input string) (string, error) {
@@ -104,8 +114,9 @@ func NewTelemetryGrantSelectors(selector string) []string {
func telemetryGrantKeyNames() []string {
names := make([]string, 0, len(telemetryGrantKeys))
for name := range telemetryGrantKeys {
names = append(names, name)
for _, canonical := range telemetryGrantKeys {
names = append(names, canonical)
}
slices.Sort(names)
return names
}

View File

@@ -14,11 +14,14 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
"builder_query/*": "builder_query/*",
"promql": "promql/*",
"clickhouse_sql": "clickhouse_sql/*",
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
"builder_query/resource.service.name/frontend": "builder_query/resource.service.name/frontend",
"builder_query/resource.service.name/*": "builder_query/resource.service.name/*",
"builder_query/resource.deployment.environment.name/prod": "builder_query/resource.deployment.environment.name/prod",
}
for input, expected := range valid {
canonical, err := NewTelemetryGrantSelector(input)
@@ -32,6 +35,10 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
"signoz.workspace.key.id = 'key-a'",
"builder_trace_operator/signoz.workspace.key.id/key-a",
"builder_query/service.name/frontend",
"builder_query/deployment.environment.name/prod",
"builder_query/resource.deployment.environment/prod",
"builder_query/attribute.service.name/frontend",
"builder_query/host.name/frontend",
"builder_query/signoz.workspace.key.id/",
"builder_query/signoz.workspace.key.id/$svc",
"*/signoz.workspace.key.id/key-a",
@@ -48,8 +55,10 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
func TestNewTelemetryGrantKey(t *testing.T) {
valid := map[string]string{
"signoz.workspace.key.id": "signoz.workspace.key.id",
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
"signoz.workspace.key.id": "signoz.workspace.key.id",
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
"resource.service.name": "resource.service.name",
"resource.deployment.environment.name": "resource.deployment.environment.name",
}
for keyText, expected := range valid {
key, ok := NewTelemetryGrantKey(keyText)
@@ -57,7 +66,7 @@ func TestNewTelemetryGrantKey(t *testing.T) {
assert.Equal(t, expected, key, keyText)
}
for _, keyText := range []string{"service.name", "attribute.signoz.workspace.key.id", "body.signoz.workspace.key.id"} {
for _, keyText := range []string{"service.name", "deployment.environment.name", "resource.deployment.environment", "host.name", "attribute.signoz.workspace.key.id", "attribute.service.name", "body.signoz.workspace.key.id"} {
_, ok := NewTelemetryGrantKey(keyText)
assert.False(t, ok, keyText)
}

View File

@@ -188,7 +188,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{
@@ -199,7 +201,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{
@@ -420,7 +424,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{
@@ -431,7 +437,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{

View File

@@ -250,7 +250,7 @@ def test_filtered_routes_allow_granted_key(
timeout=10,
)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
assert "builder_query/signoz.workspace.key.id/key-b" in denied.text
assert "builder_query/signoz.workspace.key.id = 'key-b'" in denied.text
@pytest.mark.parametrize(

View File

@@ -50,7 +50,9 @@ def test_setup(
[
"signoz.workspace.key.id = 'key-a'", # expression form, not the wire form
"unknown_query_type/signoz.workspace.key.id/key-a", # unsupported query type
"builder_query/service.name/frontend", # service.name is not a supported grant key
"builder_query/service.name/frontend", # customer keys must carry the resource prefix
"builder_query/attribute.service.name/frontend", # grant keys are resource attributes only
"builder_query/host.name/frontend", # host.name is not a supported grant key
"*/signoz.workspace.key.id/key-a", # non-prefix wildcard
"builder_query/signoz.workspace.key.id/", # empty value
"builder_query/signoz.workspace.key.id", # missing value, not a wildcard
@@ -153,7 +155,7 @@ def test_denied_message_names_resource(
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert "builder_query/signoz.workspace.key.id/key-b" in response.text
assert "builder_query/signoz.workspace.key.id = 'key-b'" in response.text
def test_variables_resolve_into_gate(

View File

@@ -0,0 +1,135 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
from fixtures.logs import Logs
from fixtures.querier import build_raw_query, make_query_request
from fixtures.role import transaction_group
user_password = "password123Z$"
service_role = "telemetry-scope-service"
service_email = "scope-service@telemetry.test"
env_role = "telemetry-scope-env"
env_email = "scope-env@telemetry.test"
two_services_role = "telemetry-scope-two-services"
two_services_email = "scope-two-services@telemetry.test"
two_keys_role = "telemetry-scope-two-keys"
two_keys_email = "scope-two-keys@telemetry.test"
mixed_role = "telemetry-scope-mixed"
mixed_email = "scope-mixed@telemetry.test"
key_wild_role = "telemetry-scope-key-wild"
key_wild_email = "scope-key-wild@telemetry.test"
two_lists = "resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for role, email, selectors in [
(service_role, service_email, ["builder_query/resource.service.name/checkout"]),
(env_role, env_email, ["builder_query/resource.deployment.environment.name/prod"]),
(two_services_role, two_services_email, ["builder_query/resource.service.name/checkout", "builder_query/resource.service.name/frontend"]),
(two_keys_role, two_keys_email, ["builder_query/signoz.workspace.key.id/k1", "builder_query/signoz.workspace.key.id/k2"]),
(mixed_role, mixed_email, ["builder_query/resource.service.name/checkout", "builder_query/signoz.workspace.key.id/k2"]),
(key_wild_role, key_wild_email, ["builder_query/signoz.workspace.key.id/*"]),
]:
create_role(admin_token, role, [transaction_group("read", "telemetryresource", "logs", selectors)])
user_id = create_active_user(signoz, admin_token, email=email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", role)
@pytest.fixture(name="seed_logs")
def seed_logs_fixture(insert_logs: Callable[[list[Logs]], None]) -> datetime:
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "checkout", "signoz.workspace.key.id": "k1", "deployment.environment.name": "prod"}, body="checkout-k1"),
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": "frontend", "signoz.workspace.key.id": "k2", "deployment.environment.name": "prod"}, body="frontend-k2"),
]
)
return now
@pytest.mark.parametrize(
("email", "expression"),
[
(service_email, "resource.service.name = 'checkout'"),
(service_email, "resource.service.name IN ('checkout')"),
(service_email, "resource.service.name = 'checkout' AND severity_text = 'ERROR'"),
# any granted atom selects the query; the other atoms only narrow it
(service_email, "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
(env_email, "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
(service_email, "resource.service.name IN ('checkout') AND signoz.workspace.key.id IN ('k1', 'k2')"),
# a single list is a union: every value, or another granted atom, covers it
(two_services_email, "resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1')"),
(two_keys_email, "resource.service.name IN ('checkout') AND signoz.workspace.key.id IN ('k1', 'k2')"),
# two lists are not expanded: only a key wildcard covers them
(key_wild_email, two_lists),
],
)
def test_allowed(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
seed_logs: datetime,
email: str,
expression: str,
) -> None:
response = make_query_request(
signoz,
get_token(email, user_password),
int((seed_logs - timedelta(minutes=10)).timestamp() * 1000),
int(seed_logs.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
@pytest.mark.parametrize(
("email", "expression", "denied_resource"),
[
(service_email, "service.name = 'checkout'", "builder_query/*"), # bare customer key never selects
# NOT(NOT a AND NOT b) == a OR b: nested negations must not mark either value as a required atom
(service_email, "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')", "builder_query/*"),
(service_email, "resource.deployment.environment.name = 'prod'", "builder_query/resource.deployment.environment.name = 'prod'"),
(service_email, "resource.service.name = 'frontend'", "builder_query/resource.service.name = 'frontend'"),
(env_email, "resource.service.name = 'checkout'", "builder_query/resource.service.name = 'checkout'"),
(
service_email,
"resource.service.name IN ('checkout', 'frontend') AND resource.deployment.environment.name = 'prod'",
"builder_query/resource.deployment.environment.name = 'prod' AND resource.service.name = 'frontend'",
),
# two lists are not expanded, so value grants never cover them
(two_services_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
(two_keys_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
(mixed_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
],
)
def test_denied(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
seed_logs: datetime,
email: str,
expression: str,
denied_resource: str,
) -> None:
response = make_query_request(
signoz,
get_token(email, user_password),
int((seed_logs - timedelta(minutes=10)).timestamp() * 1000),
int(seed_logs.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert denied_resource in response.text

View File

@@ -14,6 +14,9 @@ from fixtures.auth import (
from fixtures.role import find_role_by_name, transaction_group
from fixtures.types import Operation, SigNoz, TestContainerDocker
V2_RULES_URL = "/api/v2/rules"
V3_RULES_URL = "/api/v3/rules"
DOWNTIME_URL = "/api/v1/downtime_schedules"
ROUTE_POLICIES_URL = "/api/v1/route_policies"
_EDITOR_EMAIL = "editor+alertauthz@integration.test"
@@ -24,6 +27,9 @@ _ACTOR_EMAIL = "customrole+alertauthz@integration.test"
_ACTOR_PASSWORD = "password123Z$"
_ACTOR_ROLE_NAME = "alert-authz-actor"
_TARGET_RULE = "alert authz target"
_DOWNTIME_NAME = "alert-authz-downtime"
_ACTOR_DOWNTIME_NAME = "alert-authz-actor-downtime"
_ROUTE_POLICY_NAME = "alert-authz-route-policy"
_ACTOR_ROUTE_POLICY_NAME = "alert-authz-actor-route-policy"
@@ -32,6 +38,43 @@ SEED_CHANNEL_ALT = {"name": "alert-authz-channel-alt", "email_configs": [{"to":
ROUTE_POLICY_TEMPLATE = {"expression": 'threshold.name == "critical"', "channels": [SEED_CHANNEL["name"]], "description": "authz coverage policy"}
RULE_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [SEED_CHANNEL["name"]]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "alert_authz_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
}
RULE_TEMPLATE = {
"description": "authz coverage rule",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": RULE_CONDITION,
"labels": {"severity": "critical"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
"notificationSettings": {"groupBy": [], "usePolicy": False, "renotify": {"enabled": False, "interval": "30m", "alertStates": []}},
"version": "v5",
"schemaVersion": "v2alpha1",
}
DOWNTIME_SCHEDULE = {"timezone": "UTC", "startTime": "2026-09-21T00:00:00Z", "endTime": "2026-09-22T00:00:00Z"}
_ACTOR_BASE_TRANSACTION_GROUPS = [
transaction_group("create", "metaresource", "route-policy", ["*"]),
transaction_group("read", "metaresource", "route-policy", ["*"]),
@@ -40,6 +83,14 @@ _ACTOR_BASE_TRANSACTION_GROUPS = [
transaction_group("delete", "metaresource", "route-policy", ["*"]),
transaction_group("attach", "metaresource", "route-policy", ["*"]),
transaction_group("detach", "metaresource", "route-policy", ["*"]),
transaction_group("create", "metaresource", "planned-maintenance", ["*"]),
transaction_group("read", "metaresource", "planned-maintenance", ["*"]),
transaction_group("list", "metaresource", "planned-maintenance", ["*"]),
transaction_group("update", "metaresource", "planned-maintenance", ["*"]),
transaction_group("delete", "metaresource", "planned-maintenance", ["*"]),
transaction_group("attach", "metaresource", "planned-maintenance", ["*"]),
transaction_group("detach", "metaresource", "planned-maintenance", ["*"]),
transaction_group("attach", "metaresource", "rule", ["*"]),
]
@@ -97,6 +148,29 @@ def test_setup_users_and_channels(
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for rule in response.json()["data"]["rules"]:
if rule["alert"] == _TARGET_RULE:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={**RULE_TEMPLATE, "alert": _TARGET_RULE},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME},
@@ -181,7 +255,7 @@ def test_admin_allowed_on_route_policy_writes(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]]
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]], response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
@@ -207,6 +281,125 @@ def test_route_policy_naming_unknown_channel_rejected(
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_editor_allowed_on_downtime_mutations(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window updated", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_editor_allowed_on_downtime_naming_rules(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_viewer_allowed_on_downtime_reads_and_forbidden_on_writes(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
for path in (DOWNTIME_URL, f"{DOWNTIME_URL}/{downtime_id}"):
response = requests.get(
signoz.self.host_configs["8080"].get(path),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
for method, path in (
("post", DOWNTIME_URL),
("put", f"{DOWNTIME_URL}/{downtime_id}"),
("delete", f"{DOWNTIME_URL}/{downtime_id}"),
):
response = getattr(requests, method)(
signoz.self.host_configs["8080"].get(path),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{method.upper()} {path}: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -256,6 +449,63 @@ def test_route_policy_managed_role_tuples_enforced_with_license(
assert response.status_code == HTTPStatus.NO_CONTENT, f"admin delete must pass on the migrated route-policy delete tuple: {response.text}"
def test_downtime_managed_role_tuples_enforced_with_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"admin rule list must pass: {response.text}"
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz licensed window with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"editor create with a rule must pass on the migrated planned-maintenance create and rule attach tuples: {response.text}"
downtime_id = response.json()["data"]["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"viewer read must pass on the migrated planned-maintenance read tuple: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz licensed window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"viewer update must be forbidden, the migration grants viewers no planned-maintenance update tuple: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz licensed window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"editor widen to all rules must pass on the migrated rule attach and detach tuples: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"editor delete must pass on the migrated planned-maintenance delete tuple: {response.text}"
def test_setup_channel_scoped_actor(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -370,7 +620,7 @@ def test_route_policy_channel_changes_follow_channel_attach_and_detach(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]]
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]], response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
@@ -380,6 +630,179 @@ def test_route_policy_channel_changes_follow_channel_attach_and_detach(
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_downtime_naming_rule_forbidden_without_rule_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": [group for group in _ACTOR_BASE_TRANSACTION_GROUPS if group != transaction_group("attach", "metaresource", "rule", ["*"])]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
for alert_ids in ([target_id], []):
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": alert_ids},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"alertIds={alert_ids}: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_downtime_rule_removal_forbidden_without_rule_detach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window still with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_downtime_widening_to_all_rules_forbidden_without_wildcard_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={
"description": "",
"transactionGroups": [group for group in _ACTOR_BASE_TRANSACTION_GROUPS if group != transaction_group("attach", "metaresource", "rule", ["*"])]
+ [
transaction_group("attach", "metaresource", "rule", [target_id]),
transaction_group("detach", "metaresource", "rule", ["*"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window for all rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window for all rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_alert_authz_cleanup(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -387,6 +810,43 @@ def test_alert_authz_cleanup(
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for schedule in response.json()["data"] or []:
if schedule["name"] in (_DOWNTIME_NAME, _ACTOR_DOWNTIME_NAME):
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{schedule['id']}"),
json={"name": schedule["name"], "description": schedule["description"], "schedule": schedule["schedule"], "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{schedule['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for rule in response.json()["data"]["rules"]:
if rule["alert"] == _TARGET_RULE:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
headers={"Authorization": f"Bearer {admin_token}"},