Compare commits

..

1 Commits

Author SHA1 Message Date
vikrantgupta25
3f51075634 feat(authz): support service and environment keys in telemetry selectors
Keys stay independent: no fold between deployment.environment and
deployment.environment.name, and the per-atom check is unchanged.
2026-10-02 19:34:00 +05:30
15 changed files with 457 additions and 221 deletions

View File

@@ -155,39 +155,6 @@ describe('ResetPassword Component', () => {
{ timeout: 200 },
);
});
it('clears password mismatch error when confirm password is emptied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'password123');
await user.type(confirmPasswordInput, 'password456');
await user.tab();
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
});
await user.clear(confirmPasswordInput);
await waitFor(() => {
expect(
screen.queryByText(/passwords don't match/i),
).not.toBeInTheDocument();
expect(submitButton).toBeDisabled();
});
});
});
describe('Successful Password Reset', () => {
@@ -321,65 +288,6 @@ describe('ResetPassword Component', () => {
expect(screen.queryByText(/invalid token/i)).not.toBeInTheDocument();
});
});
it('clears API error when the user edits the password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: {
code: 'invalid_password',
message: 'password must be at least 8 characters long',
},
}),
),
),
);
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'weak');
await user.type(confirmPasswordInput, 'weak');
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
await user.click(submitButton);
await waitFor(() => {
expect(
screen.getByText(/password must be at least 8 characters long/i),
).toBeInTheDocument();
});
await user.type(passwordInput, 'Er1!');
await waitFor(() => {
expect(
screen.queryByText(/password must be at least 8 characters long/i),
).not.toBeInTheDocument();
});
await user.type(confirmPasswordInput, 'Er1!');
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
});
describe('Loading States', () => {

View File

@@ -10,6 +10,7 @@ import { useResetPassword } from 'api/generated/services/users';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
@@ -22,7 +23,10 @@ import './ResetPassword.styles.scss';
type FormValues = { password: string; confirmPassword: string };
function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [isValidPassword, setIsValidPassword] = useState(false);
const { t } = useTranslation(['common']);
const { search } = useLocation();
const params = new URLSearchParams(search);
@@ -33,7 +37,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
mutate: resetPassword,
isLoading,
error: mutationError,
reset: resetMutation,
} = useResetPassword();
const errorMessage = useMemo(
@@ -42,30 +45,11 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
);
const [form] = Form.useForm<FormValues>();
const password = Form.useWatch('password', form);
const confirmPassword = Form.useWatch('confirmPassword', form);
const isPasswordMismatch =
Boolean(confirmPassword) && password !== confirmPassword;
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
const isValidPassword =
Boolean(password?.trim()) &&
Boolean(confirmPassword?.trim()) &&
password === confirmPassword;
const handleValuesChange = (): void => {
if (mutationError) {
resetMutation();
}
};
const handleSubmit = (): void => {
if (!token) {
return;
}
const handleFormSubmit = (): void => {
const { password } = form.getFieldsValue();
resetPassword(
{ data: { password: form.getFieldValue('password'), token } },
{ data: { password, token: token || '' } },
{
onSuccess: (): void => {
notifications.success({
@@ -79,6 +63,80 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
);
};
const validatePassword = (): boolean => {
const { password, confirmPassword } = form.getFieldsValue();
if (
password &&
confirmPassword &&
password.trim() &&
confirmPassword.trim() &&
password.length > 0 &&
confirmPassword.length > 0
) {
return password === confirmPassword;
}
return false;
};
const handleValuesChange = useDebouncedFn((): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (!password || !confirmPassword) {
setIsValidPassword(false);
}
// Only clear error if passwords match while typing (but don't set error until blur)
if (
password &&
confirmPassword &&
password.trim() &&
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
// Only clear error if passwords match, don't set error on mismatch
if (isValid) {
setConfirmPasswordError(false);
}
}
}, 100);
const handlePasswordBlur = (): void => {
const { confirmPassword } = form.getFieldsValue();
// Only validate if confirm password has a value
if (confirmPassword && confirmPassword.trim()) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (
password &&
password.trim() &&
confirmPassword &&
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleSubmit = (): void => {
const isValid = validatePassword();
setIsValidPassword(isValid);
if (token) {
handleFormSubmit();
}
};
return (
<AuthPageContainer>
<div className="reset-password-card">
@@ -100,7 +158,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
<FormContainer
form={form}
onFinish={handleSubmit}
onValuesChange={handleValuesChange}
className="reset-password-form"
>
<div className="reset-password-form-container">
@@ -114,6 +171,8 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
>
<AntdInput.Password
tabIndex={0}
onChange={handleValuesChange}
onBlur={handlePasswordBlur}
id="password"
data-testid="password"
placeholder="Enter new password"
@@ -130,7 +189,8 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<AntdInput.Password
onBlur={(): void => setConfirmPasswordTouched(true)}
onChange={handleValuesChange}
onBlur={handleConfirmPasswordBlur}
id="confirmPassword"
data-testid="confirmPassword"
placeholder="Confirm your new password"
@@ -141,7 +201,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
</div>
</div>
{showPasswordMismatchError && (
{confirmPasswordError && (
<Callout
type="error"
size="small"
@@ -153,7 +213,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
</Callout>
)}
{errorMessage && !showPasswordMismatchError && (
{errorMessage && !confirmPasswordError && (
<AuthError error={errorMessage} />
)}

View File

@@ -146,13 +146,69 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
});
it('hardcodes the key and does not let it be edited', async () => {
it('defaults the key to signoz.workspace.key.id', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
expect(keyInput).toHaveValue('signoz.workspace.key.id');
expect(keyInput).toBeDisabled();
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
});
it('rewrites the selector when another key is picked', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(
screen.getByTestId('wizard-value-input-logs-read'),
'checkout',
);
await user.click(screen.getByTestId('wizard-key-select-logs-read'));
await user.click(
await screen.findByTestId('wizard-key-option-service.name-logs-read'),
);
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/service.name/checkout',
);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/service.name/checkout'),
).resolves.toBeInTheDocument();
});
it('selects the key from a typed selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/deployment.environment/prod');
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'deployment.environment',
);
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
'prod',
);
});
it('hints when a typed selector uses an unsupported key', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/host.name/web-1');
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('"host.name" is not a supported key.');
});
it('hides Key field for query types that do not support key scoping', async () => {
@@ -163,7 +219,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await user.click(await screen.findByText('ClickHouse SQL'));
expect(
screen.queryByTestId('wizard-key-input-logs-read'),
screen.queryByTestId('wizard-key-select-logs-read'),
).not.toBeInTheDocument();
});
@@ -285,35 +341,18 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
expect(screen.getByLabelText('Any value')).toBeChecked();
});
it('keeps the key input hardcoded when the selector uses another key', async () => {
it('restores the selected key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
});
it('restores the hardcoded key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
await user.type(selectorInput, 'builder_query/host.name/web-1');
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/frontend2',
'builder_query/signoz.workspace.key.id/web-12',
);
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
});

View File

@@ -30,17 +30,27 @@ export const QUERY_TYPES: readonly QueryTypeOption[] = [
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
// mirrors telemetryGrantKeys in pkg/types/telemetrytypes/selector.go
export const SUPPORTED_GRANT_KEYS: readonly string[] = [
'signoz.workspace.key.id',
'service.name',
'deployment.environment',
'deployment.environment.name',
];
export const DEFAULT_GRANT_KEY = 'signoz.workspace.key.id';
export const ANY_RESOURCE_VALUE = '*';
export interface SelectorDraft {
queryType: QueryTypeId;
key: string;
value: string;
}
export interface ParsedSelector {
queryType?: QueryTypeId;
key?: string;
value: string;
}

View File

@@ -15,7 +15,7 @@ import { Typography } from '@signozhq/ui/typography';
import {
ANY_RESOURCE_VALUE,
QUERY_TYPES,
SUPPORTED_GRANT_KEY,
SUPPORTED_GRANT_KEYS,
} from './TelemetrySelectorWizard.constants';
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
@@ -38,6 +38,7 @@ function TelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource,
@@ -46,6 +47,7 @@ function TelemetrySelectorWizard({
canAdd,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
@@ -125,12 +127,22 @@ function TelemetrySelectorWizard({
<Typography as="label" weight="medium">
Key
</Typography>
<Input
value={SUPPORTED_GRANT_KEY}
readOnly
disabled
testId={`wizard-key-input-${testId}`}
/>
<Select value={grantKey} onChange={handleKeyChange}>
<SelectTrigger data-testid={`wizard-key-select-${testId}`}>
<SelectValue>{grantKey}</SelectValue>
</SelectTrigger>
<SelectContent withPortal={false} className={styles.selectContent}>
{SUPPORTED_GRANT_KEYS.map((supportedKey) => (
<SelectItem
key={supportedKey}
value={supportedKey}
testId={`wizard-key-option-${supportedKey}-${testId}`}
>
{supportedKey}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}

View File

@@ -2,13 +2,14 @@ import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
ParsedSelector,
QUERY_TYPES,
QueryTypeId,
QueryTypeOption,
SelectorDraft,
SelectorValidation,
SUPPORTED_GRANT_KEY,
SUPPORTED_GRANT_KEYS,
} from './TelemetrySelectorWizard.constants';
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
@@ -37,6 +38,10 @@ export function isAnyResourceValue(value: string): boolean {
return value.trim() === ANY_RESOURCE_VALUE;
}
export function isSupportedGrantKey(key: string): boolean {
return SUPPORTED_GRANT_KEYS.includes(key);
}
function splitSelector(selector: string): string[] {
const parts = selector.split('/');
@@ -47,14 +52,18 @@ function splitSelector(selector: string): string[] {
return [parts[0], parts[1], parts.slice(2).join('/')];
}
export function buildSelector({ queryType, value }: SelectorDraft): string {
export function buildSelector({
queryType,
key,
value,
}: SelectorDraft): string {
const trimmedValue = value.trim();
if (!supportsKeyScoping(queryType) || !trimmedValue) {
return `${queryType}/${ANY_RESOURCE_VALUE}`;
}
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
return `${queryType}/${key}/${trimmedValue}`;
}
export function parseSelector(selector: string): ParsedSelector {
@@ -62,6 +71,7 @@ export function parseSelector(selector: string): ParsedSelector {
return {
queryType: getQueryTypeOption(parts[0])?.id,
key: parts.length >= 3 ? parts[1] : undefined,
value: parts.length >= 3 ? parts[2] : '',
};
}
@@ -101,7 +111,9 @@ export function validateSelector(selector: string): SelectorValidation {
}
return {
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/<key>/<value> with one of: ${SUPPORTED_GRANT_KEYS.join(
', ',
)}.`,
isError: false, // intentionally not an error
};
}
@@ -128,6 +140,15 @@ export function validateSelector(selector: string): SelectorValidation {
};
}
if (!isSupportedGrantKey(key)) {
return {
message: `"${key}" is not a supported key. Use one of: ${SUPPORTED_GRANT_KEYS.join(
', ',
)}.`,
isError: false, // intentionally not an error
};
}
return {
message: `Allow ${key}=${value} for ${option.label} queries.`,
isError: false,
@@ -135,5 +156,5 @@ export function validateSelector(selector: string): SelectorValidation {
}
export function getDefaultSelector(queryType: QueryTypeId): string {
return buildSelector({ queryType, value: '' });
return buildSelector({ queryType, key: DEFAULT_GRANT_KEY, value: '' });
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
DEFAULT_QUERY_TYPE,
QueryTypeId,
QueryTypeOption,
@@ -12,6 +13,7 @@ import {
getDefaultSelector,
getQueryTypeOption,
isAnyResourceValue,
isSupportedGrantKey,
parseSelector,
validateSelector,
} from './TelemetrySelectorWizard.utils';
@@ -24,6 +26,7 @@ interface UseTelemetrySelectorWizardResult {
open: boolean;
queryType: QueryTypeId;
selectedQueryType: QueryTypeOption | undefined;
grantKey: string;
value: string;
selector: string;
isAnyResource: boolean;
@@ -32,6 +35,7 @@ interface UseTelemetrySelectorWizardResult {
canAdd: boolean;
handleOpenChange: (nextOpen: boolean) => void;
handleQueryTypeChange: (value: string | string[]) => void;
handleKeyChange: (value: string | string[]) => void;
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleAnyResourceChange: (checked: boolean) => void;
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
@@ -44,6 +48,7 @@ function useTelemetrySelectorWizard({
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
const [open, setOpen] = useState(false);
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
const [grantKey, setGrantKey] = useState(DEFAULT_GRANT_KEY);
const [value, setValue] = useState('');
const [selector, setSelector] = useState(() =>
getDefaultSelector(DEFAULT_QUERY_TYPE),
@@ -58,10 +63,17 @@ function useTelemetrySelectorWizard({
const validation = useMemo(() => validateSelector(selector), [selector]);
const applyDraft = useCallback(
(nextQueryType: QueryTypeId, nextValue: string): void => {
(nextQueryType: QueryTypeId, nextKey: string, nextValue: string): void => {
setQueryType(nextQueryType);
setGrantKey(nextKey);
setValue(nextValue);
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
setSelector(
buildSelector({
queryType: nextQueryType,
key: nextKey,
value: nextValue,
}),
);
},
[],
);
@@ -71,23 +83,30 @@ function useTelemetrySelectorWizard({
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
applyDraft(selected, keepsValue ? value : '');
applyDraft(selected, grantKey, keepsValue ? value : '');
},
[applyDraft, value],
[applyDraft, grantKey, value],
);
const handleKeyChange = useCallback(
(next: string | string[]): void => {
applyDraft(queryType, Array.isArray(next) ? next[0] : next, value);
},
[applyDraft, queryType, value],
);
const handleValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
applyDraft(queryType, event.target.value);
applyDraft(queryType, grantKey, event.target.value);
},
[applyDraft, queryType],
[applyDraft, queryType, grantKey],
);
const handleAnyResourceChange = useCallback(
(checked: boolean): void => {
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
applyDraft(queryType, grantKey, checked ? ANY_RESOURCE_VALUE : '');
},
[applyDraft, queryType],
[applyDraft, queryType, grantKey],
);
const handleSelectorChange = useCallback(
@@ -99,6 +118,9 @@ function useTelemetrySelectorWizard({
if (parsed.queryType) {
setQueryType(parsed.queryType);
}
if (parsed.key && isSupportedGrantKey(parsed.key)) {
setGrantKey(parsed.key);
}
setValue(parsed.value);
},
[],
@@ -109,6 +131,7 @@ function useTelemetrySelectorWizard({
if (!nextOpen) {
setQueryType(DEFAULT_QUERY_TYPE);
setGrantKey(DEFAULT_GRANT_KEY);
setValue('');
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
}
@@ -138,6 +161,7 @@ function useTelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource: isAnyResourceValue(value),
@@ -146,6 +170,7 @@ function useTelemetrySelectorWizard({
canAdd: !validation.isError,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,

View File

@@ -9,7 +9,6 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureJSONBodyDualIngestion = featuretypes.MustNewName("json_body_dual_ingestion")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
FeatureUseTraceAttributesJSON = featuretypes.MustNewName("use_trace_attributes_json")
@@ -65,14 +64,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureJSONBodyDualIngestion,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether the collector's normalize operator keeps the original log body so it is ingested into both the legacy body and the JSON body columns",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableMetricsReduction,
Kind: featuretypes.KindBoolean,

View File

@@ -220,26 +220,7 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
// withNormalizePipeline places normalize where the read path dictates. Ahead of user pipelines
// when queries run on body_v2 (use_json_body), so operators see the body the explorer shows.
// After them when dual ingestion alone writes body_v2, so operators keep seeing the raw body
// users still query. Absent when neither flag is on.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline) []pipelinetypes.GettablePipeline {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
dualIngestion := ic.fl.BooleanOrEmpty(ctx, flagger.FeatureJSONBodyDualIngestion, evalCtx)
switch {
case ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx):
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline(dualIngestion)}, pipelines...)
case dualIngestion:
return append(slices.Clone(pipelines), getNormalizePipeline(true))
default:
return pipelines
}
}
// stashOriginalBody makes normalize carry the pre-normalization body in an internal attribute
// for the exporter to restore into the legacy body column.
func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline {
func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
@@ -258,11 +239,10 @@ func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
JSONBodyDualIngestion: stashOriginalBody,
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
},
},
}
@@ -371,11 +351,8 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
}
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
// Under dual ingestion alone it runs after user operators and only feeds body_v2, which
// the explorer does not show yet, so the preview leaves it out. The original-body stash
// is left off: the preview has no exporter to restore and strip it.
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline(false)}, pipelines...)
pipelines = append([]pipelinetypes.GettablePipeline{ic.getNormalizePipeline()}, pipelines...)
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
@@ -396,16 +373,16 @@ func (pc *LogParsingPipelineController) AgentFeatureType() agentConf.AgentFeatur
// Implements agentConf.AgentFeature interface.
// RecommendAgentConfig generates the collector config to be sent to agents.
// The normalize pipeline (when use_json_body or json_body_dual_ingestion is on) is placed
// here, after rawPipelineData is serialized. So it is only present in the config sent to
// The normalize pipeline (when use_json_body feature flag is on) is injected here, after
// rawPipelineData is serialized. So it is only present in the config sent to
// the collector and never persisted to the database as part of the user's pipeline list.
//
// NOTE: The configId sent to agents is derived from the pipeline version number
// (e.g. "LogPipelines:5"), not the YAML content. If server-side logic changes
// the generated YAML without bumping the version (e.g. toggling the use_json_body or
// json_body_dual_ingestion flags or updating operator IfExpressions), agents that already
// applied that version will not re-apply the new config. In such cases, users must save a
// new pipeline version via the API to force agents to pick up the change.
// the generated YAML without bumping the version (e.g. toggling the use_json_body
// flag or updating operator IfExpressions), agents that already applied that version will
// not re-apply the new config. In such cases, users must save a new pipeline version
// via the API to force agents to pick up the change.
func (pc *LogParsingPipelineController) RecommendAgentConfig(
orgId valuer.UUID,
currentConfYaml []byte,
@@ -431,8 +408,10 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines)
if pc.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgId)) {
// add default normalize pipeline at the beginning, only for sending to collector
enrichedPipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, enrichedPipelines...)
}
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
if err != nil {

View File

@@ -50,6 +50,29 @@ func TestQueryRangeResources(t *testing.T) {
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
},
},
{
name: "atoms on different keys each require a grant",
body: builderQueryBody("logs", "service.name = 'checkout' AND deployment.environment = 'prod'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/deployment.environment/prod"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "environment keys are independent",
body: builderQueryBody("traces", "deployment.environment.name = 'prod'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/deployment.environment.name/prod"},
},
},
{
name: "resource prefixed service in list",
body: builderQueryBody("logs", "resource.service.name IN ('frontend', 'checkout')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/frontend"},
},
},
{
name: "no filter expression",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,

View File

@@ -122,9 +122,6 @@ type PipelineOperator struct {
EnablePaths bool `json:"enable_paths,omitempty" yaml:"enable_paths,omitempty"`
PathPrefix string `json:"path_prefix,omitempty" yaml:"path_prefix,omitempty"`
// normalize fields, set by the server only
JSONBodyDualIngestion bool `json:"-" yaml:"json_body_dual_ingestion,omitempty"`
// Used in Severity Parsing and JSON Flattening mapping
Mapping map[string][]string `json:"mapping,omitempty" yaml:"mapping,omitempty"`
// severity parser fields

View File

@@ -1,6 +1,7 @@
package telemetrytypes
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -17,7 +18,10 @@ var telemetryGrantQueryTypes = map[string]bool{
}
var telemetryGrantKeys = map[string]struct{}{
"signoz.workspace.key.id": {},
"signoz.workspace.key.id": {},
"service.name": {},
"deployment.environment": {},
"deployment.environment.name": {},
}
func NewTelemetryGrantKey(keyText string) (string, bool) {
@@ -107,5 +111,6 @@ func telemetryGrantKeyNames() []string {
for name := range telemetryGrantKeys {
names = append(names, name)
}
slices.Sort(names)
return names
}

View File

@@ -19,6 +19,12 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
"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/service.name/frontend": "builder_query/service.name/frontend",
"builder_query/resource.service.name/frontend": "builder_query/service.name/frontend",
"builder_query/service.name/*": "builder_query/service.name/*",
"builder_query/deployment.environment/prod": "builder_query/deployment.environment/prod",
"builder_query/deployment.environment.name/prod": "builder_query/deployment.environment.name/prod",
"builder_query/resource.deployment.environment/prod": "builder_query/deployment.environment/prod",
}
for input, expected := range valid {
canonical, err := NewTelemetryGrantSelector(input)
@@ -31,7 +37,8 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
"key-a",
"signoz.workspace.key.id = 'key-a'",
"builder_trace_operator/signoz.workspace.key.id/key-a",
"builder_query/service.name/frontend",
"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,13 @@ 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",
"service.name": "service.name",
"resource.service.name": "service.name",
"deployment.environment": "deployment.environment",
"deployment.environment.name": "deployment.environment.name",
"resource.deployment.environment.name": "deployment.environment.name",
}
for keyText, expected := range valid {
key, ok := NewTelemetryGrantKey(keyText)
@@ -57,7 +69,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{"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

@@ -50,7 +50,8 @@ 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/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

View File

@@ -0,0 +1,153 @@
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"
service_env_role = "telemetry-scope-service-env"
service_env_email = "scope-service-env@telemetry.test"
env_name_role = "telemetry-scope-env-name"
env_name_email = "scope-env-name@telemetry.test"
seed_resources = {"service.name": "checkout", "deployment.environment": "prod", "deployment.environment.name": "prod"}
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)
create_role(admin_token, service_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/checkout"])])
service_user = create_active_user(signoz, admin_token, email=service_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, service_user, "signoz-viewer", service_role)
create_role(
admin_token,
service_env_role,
[transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/checkout", "builder_query/deployment.environment/prod"])],
)
service_env_user = create_active_user(signoz, admin_token, email=service_env_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, service_env_user, "signoz-viewer", service_env_role)
create_role(admin_token, env_name_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/deployment.environment.name/prod"])])
env_name_user = create_active_user(signoz, admin_token, email=env_name_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, env_name_user, "signoz-viewer", env_name_role)
@pytest.mark.parametrize(
"expression",
[
"service.name = 'checkout'",
"resource.service.name = 'checkout'",
"service.name IN ('checkout')",
"service.name = 'checkout' AND severity_text = 'ERROR'",
],
)
def test_service_grant_allows_service_filter(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources=seed_resources, body="checkout-0")])
response = make_query_request(
signoz,
get_token(service_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.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(
("expression", "denied_resource"),
[
("deployment.environment = 'prod'", "builder_query/deployment.environment/prod"),
# every top-level grant-key atom needs its own grant, so narrowing by a second key is denied
("service.name = 'checkout' AND deployment.environment = 'prod'", "builder_query/deployment.environment/prod"),
("service.name = 'frontend'", "builder_query/service.name/frontend"),
],
)
def test_service_grant_denies_other_keys(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
denied_resource: str,
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources=seed_resources, body="checkout-0")])
response = make_query_request(
signoz,
get_token(service_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.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
def test_grants_on_both_keys_allow_conjunction(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources=seed_resources, body="checkout-0")])
response = make_query_request(
signoz,
get_token(service_env_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'checkout' AND deployment.environment = 'prod'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
@pytest.mark.parametrize(
("expression", "status"),
[
("deployment.environment.name = 'prod'", HTTPStatus.OK),
("deployment.environment = 'prod'", HTTPStatus.FORBIDDEN),
],
)
def test_environment_keys_are_independent(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
status: HTTPStatus,
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources=seed_resources, body="checkout-0")])
response = make_query_request(
signoz,
get_token(env_name_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
request_type="raw",
)
assert response.status_code == status, response.text