Compare commits

..

21 Commits

Author SHA1 Message Date
Karan Balani
3aad23ab1c fix: sti 2026-03-19 16:20:23 +05:30
Karan Balani
0455037025 fix: openapi specs 2026-03-19 16:20:23 +05:30
Karan Balani
3102e76e80 chore: update filenames for migrations 2026-03-19 16:20:23 +05:30
Karan Balani
774e9e49dc fix: more backward compat 2026-03-19 16:20:23 +05:30
Karan Balani
29c3d10769 fix: naming in root user service 2026-03-19 16:20:23 +05:30
Karan Balani
2a75e141dc fix: backward compatibility issues 2026-03-19 16:20:23 +05:30
Karan Balani
ea5efbb68e fix: validate role in sso mapping 2026-03-19 16:20:23 +05:30
Karan Balani
15451985e5 fix: found bugs 2026-03-19 16:20:23 +05:30
Karan Balani
ef5c36b2c3 fix: revert some role related changes 2026-03-19 16:20:23 +05:30
Karan Balani
ad83b0e09b chore: keep support for role for backward compatibility 2026-03-19 16:20:23 +05:30
Karan Balani
4f3b75a914 fix: leftovers 2026-03-19 16:20:23 +05:30
Karan Balani
809c66e27e feat: introduce user_role table 2026-03-19 16:20:23 +05:30
Karan Balani
57442ef4a4 fix: types for email and org id in storableuser struct 2026-03-19 16:20:23 +05:30
Karan Balani
267d2b1d42 chore: use deleted at also in conversions and remove user_role file, will be added in diff pr 2026-03-19 16:20:23 +05:30
Karan Balani
2add13d228 chore: revert openapi changes, keeping this clean 2026-03-19 16:20:23 +05:30
Karan Balani
c35990ead9 chore: update openapi spec 2026-03-19 16:20:23 +05:30
Karan Balani
f23a364fbe refactor: separate db and domain models for user 2026-03-19 16:20:22 +05:30
Karan Balani
29ec71b98f fix: allow gateway apis on editor access (#10646)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: allow gateway apis on editor access

* fix: fmtlint and middleware

* fix: fmtlint trailing new lines
2026-03-19 10:30:28 +00:00
Pandey
ca9cbd92e4 feat(identn): implement an impersonation identn (#10641)
* feat(identn): implement an impersonation identn

* fix: prevent nil pointer error

* feat: dry org code by implementing getbyidorname

* feat: add integration tests for root user and impersonation

* fix: fix lint
2026-03-19 10:13:12 +00:00
Abhi kumar
0faef8705d feat: added changes for spangaps thresholds (#10570)
* feat: added section in panel settings

* feat: added changes for spangaps thresholds

* chore: minor changes

* fix: fixed failing tests

* fix: minor style fixes

* chore: updated the categorisation

* feat: added chart appearance settings in panel

* feat: added fill mode in timeseries

* chore: broke down rightcontainer component into sub-components

* chore: minor cleanup

* chore: fixed disconnect points logic

* chore: added spanGaps selection component

* chore: updated rightcontainer

* fix: added fix for the UI

* chore: fixed ui issues

* chore: fixed styles

* chore: default spangaps to true

* chore: moved preparedata to utils

* chore: hidden chart appearance

* chore: pr review comments

* chore: pr review comments

* chore: updated variable names

* chore: fixed minor changes

* chore: updated test

* chore: updated test
2026-03-19 09:40:02 +00:00
Ashwin Bhatkal
2ca9085b52 chore: add eslint rule for zustand getState (#10648) 2026-03-19 09:13:09 +00:00
100 changed files with 2971 additions and 655 deletions

View File

@@ -49,6 +49,7 @@ jobs:
- ttl
- alerts
- ingestionkeys
- rootuser
sqlstore-provider:
- postgres
- sqlite

View File

@@ -328,15 +328,18 @@ user:
##################### IdentN #####################
identn:
tokenizer:
# toggle the identN resolver
# toggle tokenizer identN
enabled: true
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle the identN resolver
# toggle apikey identN
enabled: true
# headers to use for apikey identN resolver
headers:
- SIGNOZ-API-KEY
impersonation:
# toggle impersonation identN, when enabled, all requests will impersonate the root user
enabled: false

View File

@@ -598,6 +598,39 @@ components:
required:
- config
type: object
GlobaltypesAPIKeyConfig:
properties:
enabled:
type: boolean
type: object
GlobaltypesConfig:
properties:
external_url:
type: string
identN:
$ref: '#/components/schemas/GlobaltypesIdentNConfig'
ingestion_url:
type: string
type: object
GlobaltypesIdentNConfig:
properties:
apikey:
$ref: '#/components/schemas/GlobaltypesAPIKeyConfig'
impersonation:
$ref: '#/components/schemas/GlobaltypesImpersonationConfig'
tokenizer:
$ref: '#/components/schemas/GlobaltypesTokenizerConfig'
type: object
GlobaltypesImpersonationConfig:
properties:
enabled:
type: boolean
type: object
GlobaltypesTokenizerConfig:
properties:
enabled:
type: boolean
type: object
MetricsexplorertypesListMetric:
properties:
description:
@@ -2030,13 +2063,6 @@ components:
required:
- id
type: object
TypesGettableGlobalConfig:
properties:
external_url:
type: string
ingestion_url:
type: string
type: object
TypesIdentifiable:
properties:
id:
@@ -2061,6 +2087,11 @@ components:
type: string
role:
type: string
roles:
items:
type: string
nullable: true
type: array
token:
type: string
updatedAt:
@@ -2143,6 +2174,11 @@ components:
type: string
role:
type: string
roles:
items:
type: string
nullable: true
type: array
type: object
TypesPostableResetPassword:
properties:
@@ -2209,6 +2245,11 @@ components:
type: string
role:
type: string
roles:
items:
type: string
nullable: true
type: array
status:
type: string
updatedAt:
@@ -3255,7 +3296,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/TypesGettableGlobalConfig'
$ref: '#/components/schemas/GlobaltypesConfig'
status:
type: string
required:
@@ -3263,29 +3304,12 @@ paths:
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Get global config
tags:
- global
@@ -5814,9 +5838,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Get ingestion keys for workspace
tags:
- gateway
@@ -5864,9 +5888,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Create ingestion key for workspace
tags:
- gateway
@@ -5904,9 +5928,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Delete ingestion key for workspace
tags:
- gateway
@@ -5948,9 +5972,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Update ingestion key for workspace
tags:
- gateway
@@ -6005,9 +6029,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Create limit for the ingestion key
tags:
- gateway
@@ -6045,9 +6069,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Delete limit for the ingestion key
tags:
- gateway
@@ -6089,9 +6113,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Update limit for the ingestion key
tags:
- gateway
@@ -6149,9 +6173,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- EDITOR
- tokenizer:
- ADMIN
- EDITOR
summary: Search ingestion keys for workspace
tags:
- gateway

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -18,7 +20,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"log/slog"
)
type CloudIntegrationConnectionParamsResponse struct {
@@ -169,7 +170,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, []string{authtypes.SigNozViewerRoleName}, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}

View File

@@ -193,6 +193,16 @@ module.exports = {
],
},
],
'no-restricted-syntax': [
'error',
{
selector:
// TODO: Make this generic on removal of redux
"CallExpression[callee.property.name='getState'][callee.object.name=/^use/]",
message:
'Avoid calling .getState() directly. Export a standalone action from the store instead.',
},
],
},
overrides: [
{
@@ -217,5 +227,13 @@ module.exports = {
'@typescript-eslint/no-unused-vars': 'warn',
},
},
{
// Store definition files are the only place .getState() is permitted —
// they are the canonical source for standalone action exports.
files: ['**/*Store.{ts,tsx}'],
rules: {
'no-restricted-syntax': 'off',
},
},
],
};

View File

@@ -776,6 +776,45 @@ export interface GatewaytypesUpdatableIngestionKeyLimitDTO {
tags?: string[] | null;
}
export interface GlobaltypesAPIKeyConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesConfigDTO {
/**
* @type string
*/
external_url?: string;
identN?: GlobaltypesIdentNConfigDTO;
/**
* @type string
*/
ingestion_url?: string;
}
export interface GlobaltypesIdentNConfigDTO {
apikey?: GlobaltypesAPIKeyConfigDTO;
impersonation?: GlobaltypesImpersonationConfigDTO;
tokenizer?: GlobaltypesTokenizerConfigDTO;
}
export interface GlobaltypesImpersonationConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesTokenizerConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface MetricsexplorertypesListMetricDTO {
/**
* @type string
@@ -2402,17 +2441,6 @@ export interface TypesGettableAPIKeyDTO {
userId?: string;
}
export interface TypesGettableGlobalConfigDTO {
/**
* @type string
*/
external_url?: string;
/**
* @type string
*/
ingestion_url?: string;
}
export interface TypesIdentifiableDTO {
/**
* @type string
@@ -2450,6 +2478,11 @@ export interface TypesInviteDTO {
* @type string
*/
role?: string;
/**
* @type array
* @nullable true
*/
roles?: string[] | null;
/**
* @type string
*/
@@ -2569,6 +2602,11 @@ export interface TypesPostableInviteDTO {
* @type string
*/
role?: string;
/**
* @type array
* @nullable true
*/
roles?: string[] | null;
}
export interface TypesPostableResetPasswordDTO {
@@ -2677,6 +2715,11 @@ export interface TypesUserDTO {
* @type string
*/
role?: string;
/**
* @type array
* @nullable true
*/
roles?: string[] | null;
/**
* @type string
*/
@@ -3026,7 +3069,7 @@ export type GetResetPasswordToken200 = {
};
export type GetGlobalConfig200 = {
data: TypesGettableGlobalConfigDTO;
data: GlobaltypesConfigDTO;
/**
* @type string
*/

View File

@@ -5,7 +5,7 @@ import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';

View File

@@ -123,7 +123,7 @@ export const prepareUPlotConfig = ({
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: true,
spanGaps: widget.spanGaps ?? true,
lineStyle: widget.lineStyle || LineStyle.Solid,
lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline,
showPoints:

View File

@@ -4,6 +4,10 @@
font-family: 'Space Mono';
padding-bottom: 48px;
.panel-type-select {
width: 100%;
}
.section-heading {
font-family: 'Space Mono';
color: var(--bg-vanilla-400);
@@ -26,10 +30,6 @@
letter-spacing: 0.48px;
}
.panel-type-select {
width: 100%;
}
.header {
display: flex;
padding: 14px 14px 14px 12px;
@@ -216,7 +216,8 @@
.lightMode {
.right-container {
background-color: var(--bg-vanilla-100);
.section-heading {
.section-heading,
.section-heading-small {
color: var(--bg-ink-400);
}
.header {

View File

@@ -7,9 +7,10 @@ import {
} from 'lib/uPlotV2/config/types';
import { Paintbrush } from 'lucide-react';
import { FillModeSelector } from '../../components/FillModeSelector/FillModeSelector';
import { LineInterpolationSelector } from '../../components/LineInterpolationSelector/LineInterpolationSelector';
import { LineStyleSelector } from '../../components/LineStyleSelector/LineStyleSelector';
import DisconnectValuesSelector from '../../components/DisconnectValuesSelector/DisconnectValuesSelector';
import FillModeSelector from '../../components/FillModeSelector/FillModeSelector';
import LineInterpolationSelector from '../../components/LineInterpolationSelector/LineInterpolationSelector';
import LineStyleSelector from '../../components/LineStyleSelector/LineStyleSelector';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
interface ChartAppearanceSectionProps {
@@ -21,10 +22,14 @@ interface ChartAppearanceSectionProps {
setLineInterpolation: Dispatch<SetStateAction<LineInterpolation>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
spanGaps: boolean | number;
setSpanGaps: Dispatch<SetStateAction<boolean | number>>;
allowFillMode: boolean;
allowLineStyle: boolean;
allowLineInterpolation: boolean;
allowShowPoints: boolean;
allowSpanGaps: boolean;
stepInterval: number;
}
export default function ChartAppearanceSection({
@@ -36,10 +41,14 @@ export default function ChartAppearanceSection({
setLineInterpolation,
showPoints,
setShowPoints,
spanGaps,
setSpanGaps,
allowFillMode,
allowLineStyle,
allowLineInterpolation,
allowShowPoints,
allowSpanGaps,
stepInterval,
}: ChartAppearanceSectionProps): JSX.Element {
return (
<SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
@@ -66,6 +75,13 @@ export default function ChartAppearanceSection({
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
</section>
)}
{allowSpanGaps && (
<DisconnectValuesSelector
value={spanGaps}
minValue={stepInterval}
onChange={setSpanGaps}
/>
)}
</SettingsSection>
);
}

View File

@@ -178,6 +178,8 @@ describe('RightContainer - Alerts Section', () => {
setLineStyle: jest.fn(),
showPoints: false,
setShowPoints: jest.fn(),
spanGaps: false,
setSpanGaps: jest.fn(),
};
beforeEach(() => {

View File

@@ -0,0 +1,38 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types';
interface DisconnectValuesModeToggleProps {
value: DisconnectedValuesMode;
onChange: (value: DisconnectedValuesMode) => void;
}
export default function DisconnectValuesModeToggle({
value,
onChange,
}: DisconnectValuesModeToggleProps): JSX.Element {
return (
<ToggleGroup
type="single"
value={value}
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as DisconnectedValuesMode);
}
}}
>
<ToggleGroupItem value={DisconnectedValuesMode.Never} aria-label="Never">
<Typography.Text className="section-heading-small">Never</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={DisconnectedValuesMode.Threshold}
aria-label="Threshold"
>
<Typography.Text className="section-heading-small">
Threshold
</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
);
}

View File

@@ -0,0 +1,21 @@
.disconnect-values-selector {
.disconnect-values-input-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
.disconnect-values-threshold-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
.disconnect-values-threshold-input {
max-width: 160px;
height: auto;
.disconnect-values-threshold-prefix {
padding: 0 8px;
font-size: 20px;
}
}
}
}
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { Typography } from 'antd';
import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types';
import DisconnectValuesModeToggle from './DisconnectValuesModeToggle';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import './DisconnectValuesSelector.styles.scss';
const DEFAULT_THRESHOLD_SECONDS = 60;
interface DisconnectValuesSelectorProps {
value: boolean | number;
minValue: number;
onChange: (value: boolean | number) => void;
}
export default function DisconnectValuesSelector({
value,
minValue,
onChange,
}: DisconnectValuesSelectorProps): JSX.Element {
const [mode, setMode] = useState<DisconnectedValuesMode>(() => {
if (typeof value === 'number') {
return DisconnectedValuesMode.Threshold;
}
return DisconnectedValuesMode.Never;
});
const [thresholdSeconds, setThresholdSeconds] = useState<number>(
typeof value === 'number' ? value : minValue ?? DEFAULT_THRESHOLD_SECONDS,
);
useEffect(() => {
if (typeof value === 'boolean') {
setMode(DisconnectedValuesMode.Never);
} else if (typeof value === 'number') {
setMode(DisconnectedValuesMode.Threshold);
setThresholdSeconds(value);
}
}, [value]);
useEffect(() => {
if (minValue !== undefined) {
setThresholdSeconds(minValue);
if (mode === DisconnectedValuesMode.Threshold) {
onChange(minValue);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minValue]);
const handleModeChange = (newMode: DisconnectedValuesMode): void => {
setMode(newMode);
switch (newMode) {
case DisconnectedValuesMode.Never:
onChange(true);
break;
case DisconnectedValuesMode.Threshold:
onChange(thresholdSeconds);
break;
}
};
const handleThresholdChange = (seconds: number): void => {
setThresholdSeconds(seconds);
onChange(seconds);
};
return (
<section className="disconnect-values-selector control-container">
<Typography.Text className="section-heading">
Disconnect values
</Typography.Text>
<div className="disconnect-values-input-wrapper">
<DisconnectValuesModeToggle value={mode} onChange={handleModeChange} />
{mode === DisconnectedValuesMode.Threshold && (
<section className="control-container">
<Typography.Text className="section-heading">
Threshold Value
</Typography.Text>
<DisconnectValuesThresholdInput
value={thresholdSeconds}
minValue={minValue}
onChange={handleThresholdChange}
/>
</section>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,92 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Callout, Input } from '@signozhq/ui';
interface DisconnectValuesThresholdInputProps {
value: number;
onChange: (seconds: number) => void;
minValue: number;
}
export default function DisconnectValuesThresholdInput({
value,
onChange,
minValue,
}: DisconnectValuesThresholdInputProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
rangeUtil.secondsToHms(value),
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setInputValue(rangeUtil.secondsToHms(value));
setError(null);
}, [value]);
const updateValue = (txt: string): void => {
if (!txt) {
return;
}
try {
let seconds: number;
if (rangeUtil.isValidTimeSpan(txt)) {
seconds = rangeUtil.intervalToSeconds(txt);
} else {
const parsed = Number(txt);
if (Number.isNaN(parsed) || parsed <= 0) {
setError('Enter a valid duration (e.g. 1h, 10m, 1d)');
return;
}
seconds = parsed;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
setInputValue(txt);
onChange(seconds);
} catch {
setError('Invalid threshold value');
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
updateValue(e.currentTarget.value);
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>): void => {
updateValue(e.currentTarget.value);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.currentTarget.value);
if (error) {
setError(null);
}
};
return (
<div className="disconnect-values-threshold-wrapper">
<Input
name="disconnect-values-threshold"
type="text"
className="disconnect-values-threshold-input"
prefix={<span className="disconnect-values-threshold-prefix">&gt;</span>}
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
autoFocus={true}
aria-invalid={!!error}
aria-describedby={error ? 'threshold-error' : undefined}
/>
{error && (
<Callout type="error" size="small" showIcon>
{error}
</Callout>
)}
</div>
);
}

View File

@@ -9,7 +9,7 @@ interface FillModeSelectorProps {
onChange: (value: FillMode) => void;
}
export function FillModeSelector({
export default function FillModeSelector({
value,
onChange,
}: FillModeSelectorProps): JSX.Element {

View File

@@ -9,7 +9,7 @@ interface LineInterpolationSelectorProps {
onChange: (value: LineInterpolation) => void;
}
export function LineInterpolationSelector({
export default function LineInterpolationSelector({
value,
onChange,
}: LineInterpolationSelectorProps): JSX.Element {

View File

@@ -9,7 +9,7 @@ interface LineStyleSelectorProps {
onChange: (value: LineStyle) => void;
}
export function LineStyleSelector({
export default function LineStyleSelector({
value,
onChange,
}: LineStyleSelectorProps): JSX.Element {

View File

@@ -262,3 +262,17 @@ export const panelTypeVsShowPoints: {
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsSpanGaps: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Typography } from 'antd';
import { ExecStats } from 'api/v5/v5';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
@@ -11,6 +12,7 @@ import {
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import get from 'lodash-es/get';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
@@ -36,6 +38,7 @@ import {
panelTypeVsPanelTimePreferences,
panelTypeVsShowPoints,
panelTypeVsSoftMinMax,
panelTypeVsSpanGaps,
panelTypeVsStackingChartPreferences,
panelTypeVsThreshold,
panelTypeVsYAxisUnit,
@@ -68,6 +71,8 @@ function RightContainer({
setLineStyle,
showPoints,
setShowPoints,
spanGaps,
setSpanGaps,
bucketCount,
bucketWidth,
stackedBarChart,
@@ -138,6 +143,7 @@ function RightContainer({
const allowLineStyle = panelTypeVsLineStyle[selectedGraph];
const allowFillMode = panelTypeVsFillMode[selectedGraph];
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
const allowSpanGaps = panelTypeVsSpanGaps[selectedGraph];
const decimapPrecisionOptions = useMemo(
() => [
@@ -176,10 +182,26 @@ function RightContainer({
(allowFillMode ||
allowLineStyle ||
allowLineInterpolation ||
allowShowPoints),
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
allowShowPoints ||
allowSpanGaps),
[
allowFillMode,
allowLineStyle,
allowLineInterpolation,
allowShowPoints,
allowSpanGaps,
],
);
const stepInterval = useMemo(() => {
const stepIntervals: ExecStats['stepIntervals'] = get(
queryResponse,
'data.payload.data.newResult.meta.stepIntervals',
{},
);
return Math.min(...Object.values(stepIntervals));
}, [queryResponse]);
return (
<div className="right-container">
<section className="header">
@@ -237,10 +259,14 @@ function RightContainer({
setLineInterpolation={setLineInterpolation}
showPoints={showPoints}
setShowPoints={setShowPoints}
spanGaps={spanGaps}
setSpanGaps={setSpanGaps}
allowFillMode={allowFillMode}
allowLineStyle={allowLineStyle}
allowLineInterpolation={allowLineInterpolation}
allowShowPoints={allowShowPoints}
allowSpanGaps={allowSpanGaps}
stepInterval={stepInterval}
/>
)}
@@ -364,6 +390,8 @@ export interface RightContainerProps {
setLineStyle: Dispatch<SetStateAction<LineStyle>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
spanGaps: boolean | number;
setSpanGaps: Dispatch<SetStateAction<boolean | number>>;
}
RightContainer.defaultProps = {

View File

@@ -14,7 +14,6 @@ import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import i18n from 'ReactI18';
import {
fireEvent,
getByText as getByTextUtil,
render,
userEvent,
@@ -342,9 +341,8 @@ describe('Stacking bar in new panel', () => {
const STACKING_STATE_ATTR = 'data-stacking-state';
describe('when switching to BAR panel type', () => {
jest.setTimeout(10000);
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
// Mock useSearchParams to return the expected values
@@ -354,7 +352,15 @@ describe('when switching to BAR panel type', () => {
]);
});
afterEach(() => {
jest.useRealTimers();
});
it('should preserve saved stacking value of true', async () => {
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime.bind(jest),
});
const { getByTestId, getByText, container } = render(
<DashboardProvider dashboardId="">
<NewWidget
@@ -370,7 +376,7 @@ describe('when switching to BAR panel type', () => {
'true',
);
await userEvent.click(getByText('Bar')); // Panel Type Selected
await user.click(getByText('Bar')); // Panel Type Selected
// find dropdown with - .ant-select-dropdown
const panelDropdown = document.querySelector(
@@ -380,7 +386,7 @@ describe('when switching to BAR panel type', () => {
// Select TimeSeries from dropdown
const option = within(panelDropdown).getByText('Time Series');
fireEvent.click(option);
await user.click(option);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
@@ -395,7 +401,7 @@ describe('when switching to BAR panel type', () => {
expect(panelTypeDropdown2).toBeInTheDocument();
expect(getByTextUtil(panelTypeDropdown2, 'Time Series')).toBeInTheDocument();
fireEvent.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
await user.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
// find dropdown with - .ant-select-dropdown
const panelDropdown2 = document.querySelector(
@@ -403,7 +409,7 @@ describe('when switching to BAR panel type', () => {
) as HTMLElement;
// // Select BAR from dropdown
const BarOption = within(panelDropdown2).getByText('Bar');
fireEvent.click(BarOption);
await user.click(BarOption);
// Stack series should be true
checkStackSeriesState(container, true);

View File

@@ -220,6 +220,9 @@ function NewWidget({
const [showPoints, setShowPoints] = useState<boolean>(
selectedWidget?.showPoints ?? false,
);
const [spanGaps, setSpanGaps] = useState<boolean | number>(
selectedWidget?.spanGaps ?? true,
);
const [customLegendColors, setCustomLegendColors] = useState<
Record<string, string>
>(selectedWidget?.customLegendColors || {});
@@ -289,6 +292,7 @@ function NewWidget({
fillMode,
lineStyle,
showPoints,
spanGaps,
columnUnits,
bucketCount,
stackedBarChart,
@@ -328,6 +332,7 @@ function NewWidget({
fillMode,
lineStyle,
showPoints,
spanGaps,
customLegendColors,
contextLinks,
selectedWidget.columnWidths,
@@ -541,6 +546,7 @@ function NewWidget({
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
spanGaps: selectedWidget?.spanGaps ?? true,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
@@ -572,6 +578,7 @@ function NewWidget({
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
spanGaps: selectedWidget?.spanGaps ?? true,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
@@ -889,6 +896,8 @@ function NewWidget({
setLineStyle={setLineStyle}
showPoints={showPoints}
setShowPoints={setShowPoints}
spanGaps={spanGaps}
setSpanGaps={setSpanGaps}
opacity={opacity}
yAxisUnit={yAxisUnit}
columnUnits={columnUnits}

View File

@@ -6,8 +6,9 @@ import { LineChart } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import uPlot, { AlignedData, Options } from 'uplot';
import { usePlotContext } from '../context/PlotContext';
import { UPlotChartProps } from './types';
import { usePlotContext } from '../../context/PlotContext';
import { UPlotChartProps } from '../types';
import { prepareAlignedData } from './utils';
/**
* Check if dimensions have changed
@@ -83,8 +84,11 @@ export default function UPlotChart({
...configOptions,
} as Options;
// prepare final AlignedData
const preparedData = prepareAlignedData({ data, config });
// Create new plot instance
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
const plot = new uPlot(plotConfig, preparedData, containerRef.current);
if (plotRef) {
plotRef(plot);
@@ -162,7 +166,8 @@ export default function UPlotChart({
}
// Update data if only data changed
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
plotInstanceRef.current.setData(data as AlignedData);
const preparedData = prepareAlignedData({ data, config });
plotInstanceRef.current.setData(preparedData as AlignedData);
}
prevPropsRef.current = currentProps;

View File

@@ -0,0 +1,16 @@
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
import { AlignedData } from 'uplot';
export function prepareAlignedData({
data,
config,
}: {
data: AlignedData;
config: UPlotConfigBuilder;
}): AlignedData {
const seriesSpanGaps = config.getSeriesSpanGapsOptions();
return seriesSpanGaps.length > 0
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
}

View File

@@ -4,7 +4,7 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { AlignedData } from 'uplot';
import { PlotContextProvider } from '../../context/PlotContext';
import UPlotChart from '../UPlotChart';
import UPlotChart from '../UPlotChart/UPlotChart';
// ---------------------------------------------------------------------------
// Mocks
@@ -86,6 +86,7 @@ const createMockConfig = (): UPlotConfigBuilder => {
}),
getId: jest.fn().mockReturnValue(undefined),
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
getSeriesSpanGapsOptions: jest.fn().mockReturnValue([]),
} as unknown) as UPlotConfigBuilder;
};
@@ -328,6 +329,78 @@ describe('UPlotChart', () => {
});
});
describe('spanGaps data transformation', () => {
it('inserts null break points before passing data to uPlot when a gap exceeds the numeric threshold', () => {
const config = createMockConfig();
// gap 0→100 = 100 > threshold 50 → null inserted at midpoint x=50
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
const data: AlignedData = [
[0, 100],
[1, 2],
];
render(<UPlotChart config={config} data={data} width={600} height={400} />, {
wrapper: Wrapper,
});
const [, receivedData] = mockUPlotConstructor.mock.calls[0];
expect(receivedData[0]).toEqual([0, 50, 100]);
expect(receivedData[1]).toEqual([1, null, 2]);
});
it('passes data through unchanged when no gap exceeds the numeric threshold', () => {
const config = createMockConfig();
// all gaps = 10, threshold = 50 → no insertions, same reference returned
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
const data: AlignedData = [
[0, 10, 20],
[1, 2, 3],
];
render(<UPlotChart config={config} data={data} width={600} height={400} />, {
wrapper: Wrapper,
});
const [, receivedData] = mockUPlotConstructor.mock.calls[0];
expect(receivedData).toBe(data);
});
it('transforms data passed to setData when data updates and a new gap exceeds the threshold', () => {
const config = createMockConfig();
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
// initial render: gap 10 < 50, no transformation
const initialData: AlignedData = [
[0, 10],
[1, 2],
];
// updated data: gap 100 > 50 → null inserted at midpoint x=50
const newData: AlignedData = [
[0, 100],
[3, 4],
];
const { rerender } = render(
<UPlotChart config={config} data={initialData} width={600} height={400} />,
{ wrapper: Wrapper },
);
rerender(
<UPlotChart config={config} data={newData} width={600} height={400} />,
);
const receivedData = instances[0].setData.mock.calls[0][0];
expect(receivedData[0]).toEqual([0, 50, 100]);
expect(receivedData[1]).toEqual([3, null, 4]);
});
});
describe('prop updates', () => {
it('calls setData without recreating the plot when only data changes', () => {
const config = createMockConfig();

View File

@@ -14,6 +14,7 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../constants';
import { calculateWidthBasedOnStepInterval } from '../utils';
import { SeriesSpanGapsOption } from '../utils/dataUtils';
import {
ConfigBuilder,
ConfigBuilderProps,
@@ -161,6 +162,13 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.series.push(new UPlotSeriesBuilder(props));
}
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
return this.series.map((s) => {
const { spanGaps } = s.props;
return { spanGaps };
});
}
/**
* Add a hook for extensibility
*/

View File

@@ -212,7 +212,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
return {
scale: scaleKey,
label,
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
// When spanGaps is numeric, we always disable uPlot's internal
// spanGaps behavior and rely on data-prep to implement the
// threshold-based null handling. When spanGaps is boolean we
// map it directly. When spanGaps is undefined we fall back to
// the default of true.
spanGaps: typeof spanGaps === 'number' ? false : spanGaps ?? true,
value: (): string => '',
pxAlign: true,
show,

View File

@@ -40,6 +40,37 @@ describe('UPlotSeriesBuilder', () => {
expect(typeof config.value).toBe('function');
});
it('maps boolean spanGaps directly to uPlot spanGaps', () => {
const trueBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: true,
}),
);
const falseBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: false,
}),
);
const trueConfig = trueBuilder.getConfig();
const falseConfig = falseBuilder.getConfig();
expect(trueConfig.spanGaps).toBe(true);
expect(falseConfig.spanGaps).toBe(false);
});
it('disables uPlot spanGaps when spanGaps is a number', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: 10000,
}),
);
const config = builder.getConfig();
expect(config.spanGaps).toBe(false);
});
it('uses explicit lineColor when provided, regardless of mapping', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({

View File

@@ -99,6 +99,11 @@ export interface ScaleProps {
distribution?: DistributionType;
}
export enum DisconnectedValuesMode {
Never = 'never',
Threshold = 'threshold',
}
/**
* Props for configuring a series
*/
@@ -175,7 +180,16 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
pointsFilter?: Series.Points.Filter;
pointsBuilder?: Series.Points.Show;
show?: boolean;
spanGaps?: boolean;
/**
* Controls how nulls are treated for this series.
*
* - boolean: mapped directly to uPlot's spanGaps behavior
* - number: interpreted as an X-axis threshold (same unit as ref values),
* where gaps smaller than this threshold are spanned by
* converting short null runs to undefined during data prep
* while uPlot's internal spanGaps is kept disabled.
*/
spanGaps?: boolean | number;
fillColor?: string;
fillMode?: FillMode;
isDarkMode?: boolean;

View File

@@ -1,4 +1,12 @@
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
import uPlot from 'uplot';
import {
applySpanGapsToAlignedData,
insertLargeGapNullsIntoAlignedData,
isInvalidPlotValue,
normalizePlotValue,
SeriesSpanGapsOption,
} from '../dataUtils';
describe('dataUtils', () => {
describe('isInvalidPlotValue', () => {
@@ -59,4 +67,217 @@ describe('dataUtils', () => {
expect(normalizePlotValue(42.5)).toBe(42.5);
});
});
describe('insertLargeGapNullsIntoAlignedData', () => {
it('returns original data unchanged when no gap exceeds the threshold', () => {
// all gaps = 10, threshold = 25 → no insertions
const data: uPlot.AlignedData = [
[0, 10, 20, 30],
[1, 2, 3, 4],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('does not insert when the gap equals the threshold exactly', () => {
// gap = 50, threshold = 50 → condition is gap > threshold, not >=
const data: uPlot.AlignedData = [
[0, 50],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('inserts a null at the midpoint when a single gap exceeds the threshold', () => {
// gap 0→100 = 100 > 50 → insert null at x=50
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100]);
expect(result[1]).toEqual([1, null, 2]);
});
it('inserts nulls at every gap that exceeds the threshold', () => {
// gaps: 0→100=100, 100→110=10, 110→210=100; threshold=50
// → insert at 0→100 and 110→210
const data: uPlot.AlignedData = [
[0, 100, 110, 210],
[1, 2, 3, 4],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110, 160, 210]);
expect(result[1]).toEqual([1, null, 2, 3, null, 4]);
});
it('inserts null for all series at a gap triggered by any one series', () => {
// series 0: threshold=50, gap=100 → triggers insertion
// series 1: threshold=200, gap=100 → would not trigger alone
// result: both series get null at the inserted x because the x-axis is shared
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
[3, 4],
];
const options: SeriesSpanGapsOption[] = [
{ spanGaps: 50 },
{ spanGaps: 200 },
];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100]);
expect(result[1]).toEqual([1, null, 2]);
expect(result[2]).toEqual([3, null, 4]);
});
it('ignores boolean spanGaps options (only numeric values trigger insertion)', () => {
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('returns original data when series options array is empty', () => {
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const result = insertLargeGapNullsIntoAlignedData(data, []);
expect(result).toBe(data);
});
it('returns original data when there is only one x point', () => {
const data: uPlot.AlignedData = [[0], [1]];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 10 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('preserves existing null values in the series alongside inserted ones', () => {
// original series already has a null; gap 0→100 also triggers insertion
const data: uPlot.AlignedData = [
[0, 100, 110],
[1, null, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110]);
expect(result[1]).toEqual([1, null, null, 2]);
});
});
describe('applySpanGapsToAlignedData', () => {
const xs: uPlot.AlignedData[0] = [0, 10, 20, 30];
it('returns original data when there are no series', () => {
const data: uPlot.AlignedData = [xs];
const result = applySpanGapsToAlignedData(data, []);
expect(result).toBe(data);
});
it('leaves data unchanged when spanGaps is undefined', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{}];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('converts nulls to undefined when spanGaps is true', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual([1, undefined, 2, undefined]);
});
it('leaves data unchanged when spanGaps is false', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: false }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('inserts a null break point when a gap exceeds the numeric threshold', () => {
// gap 0→100 = 100 > 50 → null inserted at midpoint x=50
const data: uPlot.AlignedData = [
[0, 100, 110],
[1, 2, 3],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110]);
expect(result[1]).toEqual([1, null, 2, 3]);
});
it('returns original data when no gap exceeds the numeric threshold', () => {
// all gaps = 10, threshold = 25 → no insertions
const data: uPlot.AlignedData = [xs, [1, 2, 3, 4]];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
const result = applySpanGapsToAlignedData(data, options);
expect(result).toBe(data);
});
it('applies both numeric gap insertion and boolean null-to-undefined in one pass', () => {
// series 0: spanGaps: 50 → gap 0→100 triggers a null break at midpoint x=50
// series 1: spanGaps: true → the inserted null at x=50 becomes undefined,
// so the line spans over it rather than breaking
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
[3, 4],
];
const options: SeriesSpanGapsOption[] = [
{ spanGaps: 50 },
{ spanGaps: true },
];
const result = applySpanGapsToAlignedData(data, options);
// x-axis extended with the inserted midpoint
expect(result[0]).toEqual([0, 50, 100]);
// series 0: null at midpoint breaks the line
expect(result[1]).toEqual([1, null, 2]);
// series 1: null at midpoint converted to undefined → line spans over it
expect(result[2]).toEqual([3, undefined, 4]);
});
});
});

View File

@@ -24,10 +24,10 @@ export function isInvalidPlotValue(value: unknown): boolean {
}
// Try to parse the string as a number
const numValue = parseFloat(value);
const parsedNumber = parseFloat(value);
// If parsing failed or resulted in a non-finite number, it's invalid
if (Number.isNaN(numValue) || !Number.isFinite(numValue)) {
if (Number.isNaN(parsedNumber) || !Number.isFinite(parsedNumber)) {
return true;
}
}
@@ -51,3 +51,178 @@ export function normalizePlotValue(
// Already a valid number
return value as number;
}
export interface SeriesSpanGapsOption {
spanGaps?: boolean | number;
}
// Internal type alias: a series value array that may contain nulls/undefineds.
// uPlot uses null to draw a visible gap and undefined to represent "no sample"
// (the line continues across undefined points but breaks at null ones).
type SeriesArray = Array<number | null | undefined>;
/**
* Returns true if the given gap size exceeds the numeric spanGaps threshold
* of at least one series. Used to decide whether to insert a null break point.
*/
function gapExceedsThreshold(
gapSize: number,
seriesOptions: SeriesSpanGapsOption[],
): boolean {
return seriesOptions.some(
({ spanGaps }) =>
typeof spanGaps === 'number' && spanGaps > 0 && gapSize > spanGaps,
);
}
/**
* For each series with a numeric spanGaps threshold, insert a null data point
* between consecutive x timestamps whose gap exceeds the threshold.
*
* Why: uPlot draws a continuous line between all non-null points. When the
* time gap between two consecutive samples is larger than the configured
* spanGaps value, we inject a synthetic null at the midpoint so uPlot renders
* a visible break instead of a misleading straight line across the gap.
*
* Because uPlot's AlignedData shares a single x-axis across all series, a null
* is inserted for every series at each position where any series needs a break.
*
* Two-pass approach for performance:
* Pass 1 — count how many nulls will be inserted (no allocations).
* Pass 2 — fill pre-allocated output arrays by index (no push/reallocation).
*/
export function insertLargeGapNullsIntoAlignedData(
data: uPlot.AlignedData,
seriesOptions: SeriesSpanGapsOption[],
): uPlot.AlignedData {
const [xValues, ...seriesValues] = data;
if (
!Array.isArray(xValues) ||
xValues.length < 2 ||
seriesValues.length === 0
) {
return data;
}
const timestamps = xValues as number[];
const totalPoints = timestamps.length;
// Pass 1: count insertions needed so we know the exact output length.
// This lets us pre-allocate arrays rather than growing them dynamically.
let insertionCount = 0;
for (let i = 0; i < totalPoints - 1; i += 1) {
if (gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions)) {
insertionCount += 1;
}
}
// No gaps exceed any threshold — return the original data unchanged.
if (insertionCount === 0) {
return data;
}
// Pass 2: build output arrays of exact size and fill them.
// `writeIndex` is the write cursor into the output arrays.
const outputLen = totalPoints + insertionCount;
const newX = new Array<number>(outputLen);
const newSeries: SeriesArray[] = seriesValues.map(
() => new Array<number | null | undefined>(outputLen),
);
let writeIndex = 0;
for (let i = 0; i < totalPoints; i += 1) {
// Copy the real data point at position i
newX[writeIndex] = timestamps[i];
for (
let seriesIndex = 0;
seriesIndex < seriesValues.length;
seriesIndex += 1
) {
newSeries[seriesIndex][writeIndex] = (seriesValues[
seriesIndex
] as SeriesArray)[i];
}
writeIndex += 1;
// If the gap to the next x timestamp exceeds the threshold, insert a
// synthetic null at the midpoint. The midpoint x is placed halfway
// between timestamps[i] and timestamps[i+1] (minimum 1 unit past timestamps[i] to stay unique).
if (
i < totalPoints - 1 &&
gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions)
) {
newX[writeIndex] =
timestamps[i] +
Math.max(1, Math.floor((timestamps[i + 1] - timestamps[i]) / 2));
for (
let seriesIndex = 0;
seriesIndex < seriesValues.length;
seriesIndex += 1
) {
newSeries[seriesIndex][writeIndex] = null; // null tells uPlot to break the line here
}
writeIndex += 1;
}
}
return [newX, ...newSeries] as uPlot.AlignedData;
}
/**
* Apply per-series spanGaps (boolean | number) handling to an aligned dataset.
*
* spanGaps controls how uPlot handles gaps in a series:
* - boolean true → convert null → undefined so uPlot spans over every gap
* (draws a continuous line, skipping missing samples)
* - boolean false → no change; nulls render as visible breaks (default)
* - number → insert a null break point between any two consecutive
* timestamps whose difference exceeds the threshold;
* gaps smaller than the threshold are left as-is
*
* The input data is expected to be of the form:
* [xValues, series1Values, series2Values, ...]
*/
export function applySpanGapsToAlignedData(
data: uPlot.AlignedData,
seriesOptions: SeriesSpanGapsOption[],
): uPlot.AlignedData {
const [xValues, ...seriesValues] = data;
if (!Array.isArray(xValues) || seriesValues.length === 0) {
return data;
}
// Numeric spanGaps: operates on the whole dataset at once because inserting
// null break points requires modifying the shared x-axis.
const hasNumericSpanGaps = seriesOptions.some(
({ spanGaps }) => typeof spanGaps === 'number',
);
const gapProcessed = hasNumericSpanGaps
? insertLargeGapNullsIntoAlignedData(data, seriesOptions)
: data;
// Boolean spanGaps === true: convert null → undefined per series so uPlot
// draws a continuous line across missing samples instead of breaking it.
// Skip this pass entirely if no series uses spanGaps: true.
const hasBooleanTrue = seriesOptions.some(({ spanGaps }) => spanGaps === true);
if (!hasBooleanTrue) {
return gapProcessed;
}
const [newX, ...newSeries] = gapProcessed;
const transformedSeries = newSeries.map((yValues, seriesIndex) => {
const { spanGaps } = seriesOptions[seriesIndex] ?? {};
if (spanGaps !== true) {
// This series doesn't use spanGaps: true — leave it unchanged.
return yValues;
}
// Replace null with undefined: uPlot skips undefined points without
// breaking the line, effectively spanning over the gap.
return (yValues as SeriesArray).map((pointValue) =>
pointValue === null ? undefined : pointValue,
) as uPlot.AlignedData[0];
});
return [newX, ...transformedSeries] as uPlot.AlignedData;
}

View File

@@ -141,6 +141,7 @@ export interface IBaseWidget {
showPoints?: boolean;
lineStyle?: LineStyle;
fillMode?: FillMode;
spanGaps?: boolean | number;
}
export interface Widgets extends IBaseWidget {
query: Query;

View File

@@ -10,7 +10,7 @@ import (
)
func (provider *provider) addGatewayRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{
ID: "GetIngestionKeys",
Tags: []string{"gateway"},
Summary: "Get ingestion keys for workspace",
@@ -23,12 +23,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{
ID: "SearchIngestionKeys",
Tags: []string{"gateway"},
Summary: "Search ingestion keys for workspace",
@@ -41,12 +41,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{
ID: "CreateIngestionKey",
Tags: []string{"gateway"},
Summary: "Create ingestion key for workspace",
@@ -58,12 +58,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{
ID: "UpdateIngestionKey",
Tags: []string{"gateway"},
Summary: "Update ingestion key for workspace",
@@ -75,12 +75,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{
ID: "DeleteIngestionKey",
Tags: []string{"gateway"},
Summary: "Delete ingestion key for workspace",
@@ -92,12 +92,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{
ID: "CreateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Create limit for the ingestion key",
@@ -109,12 +109,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{
ID: "UpdateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Update limit for the ingestion key",
@@ -126,12 +126,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{
ID: "DeleteIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Delete limit for the ingestion key",
@@ -143,7 +143,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -4,24 +4,24 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
"github.com/gorilla/mux"
)
func (provider *provider) addGlobalRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.EditAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{
if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.OpenAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{
ID: "GetGlobalConfig",
Tags: []string{"global"},
Summary: "Get global config",
Description: "This endpoint returns global config",
Request: nil,
RequestContentType: "",
Response: new(types.GettableGlobalConfig),
Response: new(globaltypes.Config),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: nil,
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -238,7 +238,7 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
return []handler.OpenAPISecurityScheme{
{Name: authtypes.IdentNProviderAPIkey.StringValue(), Scopes: []string{role.String()}},
{Name: authtypes.IdentNProviderAPIKey.StringValue(), Scopes: []string{role.String()}},
{Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: []string{role.String()}},
}
}

View File

@@ -186,7 +186,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint lists all users",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableUser, 0),
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -203,7 +203,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint returns the user I belong to",
Request: nil,
RequestContentType: "",
Response: new(types.GettableUser),
Response: new(types.User),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -220,7 +220,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(types.GettableUser),
Response: new(types.User),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
@@ -237,7 +237,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint updates the user by id",
Request: new(types.User),
RequestContentType: "application/json",
Response: new(types.GettableUser),
Response: new(types.User),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -17,8 +17,8 @@ func NewStore(sqlstore sqlstore.SQLStore) authtypes.AuthNStore {
return &store{sqlstore: sqlstore}
}
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error) {
user := new(types.User)
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.StorableUser, *types.FactorPassword, error) {
user := new(types.StorableUser)
factorPassword := new(types.FactorPassword)
err := store.

View File

@@ -1,9 +1,14 @@
package global
import "net/http"
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
)
type Global interface {
GetConfig() Config
GetConfig(context.Context) *globaltypes.Config
}
type Handler interface {

View File

@@ -1,11 +1,12 @@
package signozglobal
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types"
)
type handler struct {
@@ -17,7 +18,10 @@ func NewHandler(global global.Global) global.Handler {
}
func (handler *handler) GetConfig(rw http.ResponseWriter, r *http.Request) {
cfg := handler.global.GetConfig()
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
render.Success(rw, http.StatusOK, types.NewGettableGlobalConfig(cfg.ExternalURL, cfg.IngestionURL))
cfg := handler.global.GetConfig(ctx)
render.Success(rw, http.StatusOK, cfg)
}

View File

@@ -5,27 +5,38 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
)
type provider struct {
config global.Config
settings factory.ScopedProviderSettings
config global.Config
identNConfig identn.Config
settings factory.ScopedProviderSettings
}
func NewFactory() factory.ProviderFactory[global.Global, global.Config] {
func NewFactory(identNConfig identn.Config) factory.ProviderFactory[global.Global, global.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) {
return newProvider(ctx, providerSettings, config)
return newProvider(ctx, providerSettings, config, identNConfig)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) {
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config, identNConfig identn.Config) (global.Global, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/global/signozglobal")
return &provider{
config: config,
settings: settings,
config: config,
identNConfig: identNConfig,
settings: settings,
}, nil
}
func (provider *provider) GetConfig() global.Config {
return provider.config
func (provider *provider) GetConfig(context.Context) *globaltypes.Config {
return globaltypes.NewConfig(
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String()),
globaltypes.NewIdentNConfig(
globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled},
globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled},
globaltypes.ImpersonationConfig{Enabled: provider.identNConfig.Impersonation.Enabled},
),
)
}

View File

@@ -40,7 +40,7 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -90,7 +90,7 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -139,7 +139,7 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)

View File

@@ -25,7 +25,7 @@ type provider struct {
}
func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIkey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIKey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(providerSettings, store, config)
})
}
@@ -40,7 +40,7 @@ func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, con
}
func (provider *provider) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderAPIkey
return authtypes.IdentNProviderAPIKey
}
func (provider *provider) Test(req *http.Request) bool {
@@ -52,10 +52,6 @@ func (provider *provider) Test(req *http.Request) bool {
return false
}
func (provider *provider) Enabled() bool {
return provider.config.APIKeyConfig.Enabled
}
func (provider *provider) Pre(req *http.Request) *http.Request {
token := provider.extractToken(req)
if token == "" {
@@ -89,7 +85,7 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "api key has expired")
}
var user types.User
var user types.StorableUser
err = provider.
store.
BunDB().

View File

@@ -1,6 +1,7 @@
package identn
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
@@ -10,11 +11,20 @@ type Config struct {
// Config for apikey identN resolver
APIKeyConfig APIKeyConfig `mapstructure:"apikey"`
// Config for impersonation identN resolver
Impersonation ImpersonationConfig `mapstructure:"impersonation"`
}
type ImpersonationConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
}
type TokenizerConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
// Headers to extract from incoming requests
Headers []string `mapstructure:"headers"`
}
@@ -22,6 +32,7 @@ type TokenizerConfig struct {
type APIKeyConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
// Headers to extract from incoming requests
Headers []string `mapstructure:"headers"`
}
@@ -40,9 +51,22 @@ func newConfig() factory.Config {
Enabled: true,
Headers: []string{"SIGNOZ-API-KEY"},
},
Impersonation: ImpersonationConfig{
Enabled: false,
},
}
}
func (c Config) Validate() error {
if c.Impersonation.Enabled {
if c.Tokenizer.Enabled {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::tokenizer is enabled")
}
if c.APIKeyConfig.Enabled {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::apikey is enabled")
}
}
return nil
}

View File

@@ -23,8 +23,6 @@ type IdentN interface {
GetIdentity(r *http.Request) (*authtypes.Identity, error)
Name() authtypes.IdentNProvider
Enabled() bool
}
// IdentNWithPreHook is optionally implemented by resolvers that need to

View File

@@ -0,0 +1,96 @@
package impersonationidentn
import (
"context"
"net/http"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type provider struct {
config identn.Config
settings factory.ScopedProviderSettings
orgGetter organization.Getter
userGetter user.Getter
userConfig user.Config
mu sync.RWMutex
identity *authtypes.Identity
}
func NewFactory(orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderImpersonation.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(ctx, providerSettings, config, orgGetter, userGetter, userConfig)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) (identn.IdentN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/impersonationidentn")
settings.Logger().WarnContext(ctx, "impersonation identity provider is enabled, all requests will impersonate the root user")
if !userConfig.Root.Enabled {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "root user is not enabled, impersonation identity provider will not be able to resolve any identity")
}
return &provider{
config: config,
settings: settings,
orgGetter: orgGetter,
userGetter: userGetter,
userConfig: userConfig,
}, nil
}
func (provider *provider) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderImpersonation
}
func (provider *provider) Test(_ *http.Request) bool {
return true
}
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
provider.mu.RLock()
if provider.identity != nil {
provider.mu.RUnlock()
return provider.identity, nil
}
provider.mu.RUnlock()
provider.mu.Lock()
defer provider.mu.Unlock()
// Re-check after acquiring write lock; another goroutine may have resolved it.
if provider.identity != nil {
return provider.identity, nil
}
org, _, err := provider.orgGetter.GetByIDOrName(ctx, provider.userConfig.Root.Org.ID, provider.userConfig.Root.Org.Name)
if err != nil {
return nil, err
}
rootUser, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
if err != nil {
return nil, err
}
provider.identity = authtypes.NewIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
rootUser.Role,
authtypes.IdentNProviderImpersonation,
)
return provider.identity, nil
}

View File

@@ -1,9 +1,11 @@
package identn
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type identNResolver struct {
@@ -11,19 +13,55 @@ type identNResolver struct {
settings factory.ScopedProviderSettings
}
func NewIdentNResolver(providerSettings factory.ProviderSettings, identNs ...IdentN) IdentNResolver {
enabledIdentNs := []IdentN{}
func NewIdentNResolver(ctx context.Context, providerSettings factory.ProviderSettings, identNConfig Config, identNFactories factory.NamedMap[factory.ProviderFactory[IdentN, Config]]) (IdentNResolver, error) {
identNs := []IdentN{}
for _, identN := range identNs {
if identN.Enabled() {
enabledIdentNs = append(enabledIdentNs, identN)
if identNConfig.Impersonation.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderImpersonation.StringValue())
if err != nil {
return nil, err
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
if identNConfig.Tokenizer.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderTokenizer.StringValue())
if err != nil {
return nil, err
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
if identNConfig.APIKeyConfig.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderAPIKey.StringValue())
if err != nil {
return nil, err
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
return &identNResolver{
identNs: enabledIdentNs,
identNs: identNs,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn"),
}
}, nil
}
// GetIdentN returns the first IdentN whose Test() returns true.

View File

@@ -48,10 +48,6 @@ func (provider *provider) Test(req *http.Request) bool {
return false
}
func (provider *provider) Enabled() bool {
return provider.config.Tokenizer.Enabled
}
func (provider *provider) Pre(req *http.Request) *http.Request {
accessToken := provider.extractToken(req)
if accessToken == "" {

View File

@@ -3,6 +3,7 @@ package implorganization
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/types"
@@ -22,6 +23,33 @@ func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.Organizat
return module.store.Get(ctx, id)
}
func (module *getter) GetByIDOrName(ctx context.Context, id valuer.UUID, name string) (*types.Organization, bool, error) {
if id.IsZero() {
org, err := module.store.GetByName(ctx, name)
if err != nil {
return nil, false, err
}
return org, true, nil
}
org, err := module.store.Get(ctx, id)
if err == nil {
return org, false, nil
}
if !errors.Ast(err, errors.TypeNotFound) {
return nil, false, err
}
org, err = module.store.GetByName(ctx, name)
if err != nil {
return nil, false, err
}
return org, true, nil
}
func (module *getter) ListByOwnedKeyRange(ctx context.Context) ([]*types.Organization, error) {
start, end, err := module.sharder.GetMyOwnedKeyRange(ctx)
if err != nil {

View File

@@ -12,6 +12,10 @@ type Getter interface {
// Get gets the organization based on the given id
Get(context.Context, valuer.UUID) (*types.Organization, error)
// GetByIDOrName gets the organization by id, falling back to name on not found.
// The boolean is true when the name fallback path was used.
GetByIDOrName(context.Context, valuer.UUID, string) (*types.Organization, bool, error)
// ListByOwnedKeyRange gets all the organizations owned by the instance
ListByOwnedKeyRange(context.Context) ([]*types.Organization, error)

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
@@ -28,9 +29,10 @@ type module struct {
authDomain authdomain.Module
tokenizer tokenizer.Tokenizer
orgGetter organization.Getter
authz authz.AuthZ
}
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, user user.Module, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter) session.Module {
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, user user.Module, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
return &module{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
authNs: authNs,
@@ -39,6 +41,7 @@ func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.A
authDomain: authDomain,
tokenizer: tokenizer,
orgGetter: orgGetter,
authz: authz,
}
}
@@ -142,9 +145,16 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
}
roleMapping := authDomain.AuthDomainConfig().RoleMapping
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
managedRoles := roleMapping.ManagedRolesFromCallbackIdentity(callbackIdentity)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID, types.UserStatusActive)
// pass only valid or fallback to viewer
validRoles, err := module.resolveValidRoles(ctx, callbackIdentity.OrgID, managedRoles, callbackIdentity.Email)
if err != nil {
return "", err
}
legacyRole := authtypes.HighestLegacyRoleFromManagedRoles(validRoles)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, legacyRole, validRoles, callbackIdentity.OrgID, types.UserStatusActive)
if err != nil {
return "", err
}
@@ -222,3 +232,34 @@ func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs ma
return provider, nil
}
// resolveValidRoles validates role names against the database
// returns only roles that exist. If none are valid, falls back to signoz-viewer role
func (module *module) resolveValidRoles(ctx context.Context, orgID valuer.UUID, roles []string, email valuer.Email) ([]string, error) {
validRoles := make([]string, 0, len(roles))
var ignored []string
for _, roleName := range roles {
_, err := module.authz.GetByOrgIDAndName(ctx, orgID, roleName)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
ignored = append(ignored, roleName)
continue
}
return nil, err
}
validRoles = append(validRoles, roleName)
}
if len(ignored) > 0 {
module.settings.Logger().WarnContext(ctx, "ignoring non-existent roles from SSO mapping", "ignored_roles", ignored, "email", email)
}
// fallback to viewer if no valid roles
if len(validRoles) == 0 {
module.settings.Logger().WarnContext(ctx, "no valid roles from SSO mapping, falling back to viewer", "email", email)
validRoles = []string{authtypes.SigNozViewerRoleName}
}
return validRoles, nil
}

View File

@@ -30,7 +30,7 @@ func (module *module) Create(ctx context.Context, timestamp int64, name string,
funnel.CreatedBy = userID.String()
// Set up the user relationship
funnel.CreatedByUser = &types.User{
funnel.CreatedByUser = &types.StorableUser{
Identifiable: types.Identifiable{
ID: userID,
},

View File

@@ -2,78 +2,56 @@ package impluser
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type getter struct {
store types.UserStore
flagger flagger.Flagger
store types.UserStore
}
func NewGetter(store types.UserStore, flagger flagger.Flagger) user.Getter {
return &getter{store: store, flagger: flagger}
}
func (module *getter) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
return module.store.GetRootUserByOrgID(ctx, orgID)
func NewGetter(store types.UserStore) user.Getter {
return &getter{store: store}
}
func (module *getter) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
users, err := module.store.ListUsersByOrgID(ctx, orgID)
storableUsers, err := module.store.ListUsersByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
// filter root users if feature flag `hide_root_users` is true
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
hideRootUsers := module.flagger.BooleanOrEmpty(ctx, flagger.FeatureHideRootUser, evalCtx)
if hideRootUsers {
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.IsRoot })
// we are not resolving roles for getter methods
users := make([]*types.User, len(storableUsers))
for idx, storableUser := range storableUsers {
users[idx] = types.NewUserFromStorable(storableUser, make([]string, 0))
}
return users, nil
}
func (module *getter) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*types.User, error) {
users, err := module.store.GetUsersByEmail(ctx, email)
if err != nil {
return nil, err
}
return users, nil
}
func (module *getter) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.User, error) {
user, err := module.store.GetByOrgIDAndID(ctx, orgID, id)
if err != nil {
return nil, err
}
return user, nil
}
func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.User, error) {
user, err := module.store.GetUser(ctx, id)
storableUser, err := module.store.GetUser(ctx, id)
if err != nil {
return nil, err
}
return user, nil
return types.NewUserFromStorable(storableUser, make([]string, 0)), nil
}
func (module *getter) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
users, err := module.store.ListUsersByEmailAndOrgIDs(ctx, email, orgIDs)
storableUsers, err := module.store.ListUsersByEmailAndOrgIDs(ctx, email, orgIDs)
if err != nil {
return nil, err
}
users := make([]*types.User, len(storableUsers))
for idx, storableUser := range storableUsers {
users[idx] = types.NewUserFromStorable(storableUser, make([]string, 0))
}
return users, nil
}

View File

@@ -169,7 +169,7 @@ func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.getter.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
user, err := h.module.GetByOrgIDAndUserID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
if err != nil {
render.Error(w, err)
return
@@ -188,7 +188,7 @@ func (h *handler) GetMyUser(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.getter.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID))
user, err := h.module.GetByOrgIDAndUserID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID))
if err != nil {
render.Error(w, err)
return
@@ -207,7 +207,7 @@ func (h *handler) ListUsers(w http.ResponseWriter, r *http.Request) {
return
}
users, err := h.getter.ListByOrgID(ctx, valuer.MustNewUUID(claims.OrgID))
users, err := h.module.ListUsersByOrgID(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(w, err)
return
@@ -270,7 +270,7 @@ func (handler *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Req
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id := mux.Vars(r)["id"]
userID := mux.Vars(r)["id"]
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -278,13 +278,7 @@ func (handler *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Req
return
}
user, err := handler.getter.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
if err != nil {
render.Error(w, err)
return
}
token, err := handler.module.GetOrCreateResetPasswordToken(ctx, user.ID)
token, err := handler.module.GetOrCreateResetPasswordToken(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID))
if err != nil {
render.Error(w, err)
return

View File

@@ -11,47 +11,103 @@ import (
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/dustin/go-humanize"
)
type Module struct {
store types.UserStore
tokenizer tokenizer.Tokenizer
emailing emailing.Emailing
settings factory.ScopedProviderSettings
orgSetter organization.Setter
authz authz.AuthZ
analytics analytics.Analytics
config user.Config
store types.UserStore
userRoleStore authtypes.UserRoleStore
tokenizer tokenizer.Tokenizer
emailing emailing.Emailing
settings factory.ScopedProviderSettings
orgSetter organization.Setter
authz authz.AuthZ
analytics analytics.Analytics
config root.Config
flagger flagger.Flagger
}
// This module is a WIP, don't take inspiration from this.
func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config user.Config) root.Module {
func NewModule(store types.UserStore, userRoleStore authtypes.UserRoleStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, flagger flagger.Flagger) root.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &Module{
store: store,
tokenizer: tokenizer,
emailing: emailing,
settings: settings,
orgSetter: orgSetter,
analytics: analytics,
authz: authz,
config: config,
store: store,
userRoleStore: userRoleStore,
tokenizer: tokenizer,
emailing: emailing,
settings: settings,
orgSetter: orgSetter,
analytics: analytics,
authz: authz,
config: config,
flagger: flagger,
}
}
// this function gets user with its proper roles populated
func (m *Module) GetByOrgIDAndUserID(ctx context.Context, orgID, userID valuer.UUID) (*types.User, error) {
storableUser, err := m.store.GetByOrgIDAndID(ctx, orgID, userID)
if err != nil {
return nil, err
}
roleNames, err := m.resolveRoleNamesForUser(ctx, userID, storableUser.OrgID)
if err != nil {
return nil, err
}
user := types.NewUserFromStorable(storableUser, roleNames)
return user, nil
}
func (module *Module) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
storableUsers, err := module.store.ListUsersByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
userIDs := make([]valuer.UUID, len(storableUsers))
for idx, storableUser := range storableUsers {
userIDs[idx] = storableUser.ID
}
storableUserRoles, err := module.userRoleStore.ListUserRolesByOrgIDAndUserIDs(ctx, orgID, userIDs)
if err != nil {
return nil, err
}
userIDToRoleIDs, roleIDs := authtypes.GetUserIDToRoleIDsMappingAndUniqueRoles(storableUserRoles)
roles, err := module.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
if err != nil {
return nil, err
}
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
hideRootUsers := module.flagger.BooleanOrEmpty(ctx, flagger.FeatureHideRootUser, evalCtx)
if hideRootUsers {
storableUsers = slices.DeleteFunc(storableUsers, func(user *types.StorableUser) bool { return user.IsRoot })
}
users := module.usersFromStorableUsersAndRolesMaps(storableUsers, roles, userIDToRoleIDs)
return users, nil
}
func (m *Module) AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) {
// get the user by reset password token
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
storableUser, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
@@ -63,7 +119,7 @@ func (m *Module) AcceptInvite(ctx context.Context, token string, password string
}
// query the user again
user, err = m.store.GetByOrgIDAndID(ctx, user.OrgID, user.ID)
user, err := m.GetByOrgIDAndUserID(ctx, storableUser.OrgID, storableUser.ID)
if err != nil {
return nil, err
}
@@ -73,7 +129,12 @@ func (m *Module) AcceptInvite(ctx context.Context, token string, password string
func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) {
// get the user
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
storableUser, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
user, err := m.GetByOrgIDAndUserID(ctx, storableUser.OrgID, storableUser.ID)
if err != nil {
return nil, err
}
@@ -87,6 +148,7 @@ func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Inv
Email: user.Email,
Token: token,
Role: user.Role,
Roles: user.Roles,
OrgID: user.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: user.CreatedAt,
@@ -106,24 +168,52 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
// validate all emails to be invited
emails := make([]string, len(bulkInvites.Invites))
for idx, invite := range bulkInvites.Invites {
var allRolesFromRequest []string
seenRolesFromRequest := make(map[string]struct{})
for idx := range bulkInvites.Invites {
invite := &bulkInvites.Invites[idx]
emails[idx] = invite.Email.StringValue()
// backward compat: derive Roles from legacy Role when Roles is not provided
if len(invite.Roles) == 0 && invite.Role != "" {
if managedRole, ok := authtypes.ExistingRoleToSigNozManagedRoleMap[invite.Role]; ok {
invite.Roles = []string{managedRole}
}
} else if invite.Role == "" && len(invite.Roles) > 0 {
// and vice versa
invite.Role = authtypes.HighestLegacyRoleFromManagedRoles(invite.Roles)
}
// for role name validation
for _, role := range invite.Roles {
if _, ok := seenRolesFromRequest[role]; !ok {
seenRolesFromRequest[role] = struct{}{}
allRolesFromRequest = append(allRolesFromRequest, role)
}
}
}
users, err := m.store.GetUsersByEmailsOrgIDAndStatuses(ctx, orgID, emails, []string{types.UserStatusActive.StringValue(), types.UserStatusPendingInvite.StringValue()})
storableUsers, err := m.store.GetUsersByEmailsOrgIDAndStatuses(ctx, orgID, emails, []string{types.UserStatusActive.StringValue(), types.UserStatusPendingInvite.StringValue()})
if err != nil {
return nil, err
}
if len(users) > 0 {
if err := users[0].ErrIfRoot(); err != nil {
if len(storableUsers) > 0 {
if err := storableUsers[0].ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "Cannot send invite to root user")
}
if users[0].Status == types.UserStatusPendingInvite {
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email: %s", users[0].Email.StringValue())
if storableUsers[0].Status == types.UserStatusPendingInvite {
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email: %s", storableUsers[0].Email.StringValue())
}
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with this email: %s", users[0].Email.StringValue())
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with this email: %s", storableUsers[0].Email.StringValue())
}
// this function returns error if some role is not found by name
_, err = m.authz.ListByOrgIDAndNames(ctx, orgID, allRolesFromRequest)
if err != nil {
return nil, err
}
type userWithResetToken struct {
@@ -135,25 +225,20 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
for idx, invite := range bulkInvites.Invites {
role, err := types.NewRole(invite.Role.String())
if err != nil {
return err
}
// create a new user with pending invite status
newUser, err := types.NewUser(invite.Name, invite.Email, role, orgID, types.UserStatusPendingInvite)
newUser, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.Roles, orgID, types.UserStatusPendingInvite)
if err != nil {
return err
}
// store the user and password in db
// store the user and user_role entries in db
err = m.createUserWithoutGrant(ctx, newUser)
if err != nil {
return err
}
// generate reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, newUser.ID)
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, newUser.OrgID, newUser.ID)
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to create reset password token for invited user", "error", err)
return err
@@ -175,7 +260,7 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
for idx, userWithToken := range newUsersWithResetToken {
m.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
"invitee_email": userWithToken.User.Email,
"invitee_role": userWithToken.User.Role,
"invitee_roles": userWithToken.User.Roles,
})
invite := &types.Invite{
@@ -186,6 +271,7 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
Email: userWithToken.User.Email,
Token: userWithToken.ResetPasswordToken.Token,
Role: userWithToken.User.Role,
Roles: userWithToken.User.Roles,
OrgID: userWithToken.User.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: userWithToken.User.CreatedAt,
@@ -219,8 +305,7 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
}
func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
// find all the users with pending_invite status
users, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
users, err := m.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
if err != nil {
return nil, err
}
@@ -231,7 +316,7 @@ func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite,
for _, pUser := range pendingUsers {
// get the reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, pUser.ID)
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, pUser.OrgID, pUser.ID)
if err != nil {
return nil, err
}
@@ -245,6 +330,7 @@ func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite,
Email: pUser.Email,
Token: resetPasswordToken.Token,
Role: pUser.Role,
Roles: pUser.Roles,
OrgID: pUser.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: pUser.CreatedAt,
@@ -259,16 +345,27 @@ func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite,
}
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
// since assign is idempotant multiple calls to assign won't cause issues in case of retries.
err := module.authz.Grant(ctx, input.OrgID, []string{authtypes.MustGetSigNozManagedRoleFromExistingRole(input.Role)}, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
// validate the roles
_, err := module.authz.ListByOrgIDAndNames(ctx, input.OrgID, input.Roles)
if err != nil {
return err
}
// since assign is idempotant multiple calls to assign won't cause issues in case of retries, also we cannot run this in a transaction for now
err = module.authz.Grant(ctx, input.OrgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
if err != nil {
return err
}
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
if err := module.store.CreateUser(ctx, types.NewStorableUser(input)); err != nil {
return err
}
// create user_role junction entries
if err := module.createUserRoleEntries(ctx, input); err != nil {
return err
}
@@ -291,7 +388,7 @@ func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ..
}
func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.User, updatedBy string) (*types.User, error) {
existingUser, err := m.store.GetUser(ctx, valuer.MustNewUUID(id))
existingUser, err := m.GetByOrgIDAndUserID(ctx, orgID, valuer.MustNewUUID(id))
if err != nil {
return nil, err
}
@@ -308,18 +405,30 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
return nil, errors.WithAdditionalf(err, "cannot update pending user")
}
requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy))
requestor, err := m.GetByOrgIDAndUserID(ctx, orgID, valuer.MustNewUUID(updatedBy))
if err != nil {
return nil, err
}
if user.Role != "" && user.Role != existingUser.Role && requestor.Role != types.RoleAdmin {
// backward compatibility: convert legacy "role" field to "roles" when "roles" is not provided
if user.Roles == nil && user.Role != "" && user.Role != existingUser.Role {
user.Roles = []string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)}
}
var grants, revokes []string
var rolesChanged bool
if user.Roles != nil {
grants, revokes = existingUser.PatchRoles(user.Roles)
rolesChanged = (len(grants) > 0) || (len(revokes) > 0)
}
if rolesChanged && !slices.Contains(requestor.Roles, authtypes.SigNozAdminRoleName) {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
// Make sure that the request is not demoting the last admin user.
if user.Role != "" && user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
adminUsers, err := m.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if rolesChanged && slices.Contains(existingUser.Roles, authtypes.SigNozAdminRoleName) && !slices.Contains(user.Roles, authtypes.SigNozAdminRoleName) {
adminUsers, err := m.store.GetActiveUsersByRoleNameAndOrgID(ctx, authtypes.SigNozAdminRoleName, orgID)
if err != nil {
return nil, err
}
@@ -329,28 +438,58 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
}
}
if user.Role != "" && user.Role != existingUser.Role {
err = m.authz.ModifyGrant(ctx,
orgID,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(existingUser.Role)},
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil),
)
if rolesChanged {
// can't run in txn
err = m.authz.ModifyGrant(ctx, orgID, revokes, grants, authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
if err != nil {
return nil, err
}
}
existingUser.Update(user.DisplayName, user.Role)
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return nil, err
// preserve existing role and roles when not explicitly provided in the request
updateRole := user.Role
updateRoles := user.Roles
if user.Roles == nil {
updateRole = existingUser.Role
updateRoles = existingUser.Roles
} else if updateRole == "" {
updateRole = existingUser.Role
}
existingUser.Update(user.DisplayName, updateRole, updateRoles)
if rolesChanged {
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
// update the user
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return err
}
// delete old role entries and create new ones
if err := m.userRoleStore.DeleteUserRoles(ctx, existingUser.ID); err != nil {
return err
}
// create new ones
if err := m.createUserRoleEntries(ctx, existingUser); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
} else {
// persist display name change even when roles haven't changed
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return nil, err
}
}
return existingUser, nil
}
func (module *Module) UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
if err := module.store.UpdateUser(ctx, orgID, user); err != nil {
storableUser := types.NewStorableUser(user)
if err := module.store.UpdateUser(ctx, orgID, storableUser); err != nil {
return err
}
@@ -366,7 +505,7 @@ func (module *Module) UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user
}
func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error {
user, err := module.store.GetUser(ctx, valuer.MustNewUUID(id))
user, err := module.GetByOrgIDAndUserID(ctx, orgID, valuer.MustNewUUID(id))
if err != nil {
return err
}
@@ -384,17 +523,17 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
}
// don't allow to delete the last admin user
adminUsers, err := module.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
adminUsers, err := module.store.GetActiveUsersByRoleNameAndOrgID(ctx, authtypes.SigNozAdminRoleName, orgID)
if err != nil {
return err
}
if len(adminUsers) == 1 && user.Role == types.RoleAdmin {
if len(adminUsers) == 1 && slices.Contains(user.Roles, authtypes.SigNozAdminRoleName) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin")
}
// since revoke is idempotant multiple calls to revoke won't cause issues in case of retries
err = module.authz.Revoke(ctx, orgID, []string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)}, authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
err = module.authz.Revoke(ctx, orgID, user.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
if err != nil {
return err
}
@@ -411,8 +550,8 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return nil
}
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error) {
user, err := module.store.GetUser(ctx, userID)
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, orgID, userID valuer.UUID) (*types.ResetPasswordToken, error) {
user, err := module.GetByOrgIDAndUserID(ctx, orgID, userID)
if err != nil {
return nil, err
}
@@ -495,7 +634,7 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID)
token, err := module.GetOrCreateResetPasswordToken(ctx, orgID, user.ID)
if err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to create reset password token", "error", err)
return err
@@ -541,17 +680,17 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
user, err := module.store.GetUser(ctx, valuer.MustNewUUID(password.UserID))
storableUser, err := module.store.GetUser(ctx, valuer.MustNewUUID(password.UserID))
if err != nil {
return err
}
// handle deleted user
if err := user.ErrIfDeleted(); err != nil {
if err := storableUser.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "deleted users cannot reset their password")
}
if err := user.ErrIfRoot(); err != nil {
if err := storableUser.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
@@ -559,12 +698,19 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
roleNames, err := module.resolveRoleNamesForUser(ctx, storableUser.ID, storableUser.OrgID)
if err != nil {
return err
}
user := types.NewUserFromStorable(storableUser, roleNames)
// since grant is idempotent, multiple calls won't cause issues in case of retries
if user.Status == types.UserStatusPendingInvite {
if err = module.authz.Grant(
ctx,
user.OrgID,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
user.Roles,
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
); err != nil {
return err
@@ -576,7 +722,7 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
if err := module.store.UpdateUser(ctx, user.OrgID, user); err != nil {
if err := module.store.UpdateUser(ctx, user.OrgID, types.NewStorableUser(user)); err != nil {
return err
}
}
@@ -594,16 +740,16 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
}
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
user, err := module.store.GetUser(ctx, userID)
storableUser, err := module.store.GetUser(ctx, userID)
if err != nil {
return err
}
if err := user.ErrIfDeleted(); err != nil {
if err := storableUser.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for deleted user")
}
if err := user.ErrIfRoot(); err != nil {
if err := storableUser.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for root user")
}
@@ -648,10 +794,12 @@ func (module *Module) GetOrCreateUser(ctx context.Context, user *types.User, opt
if existingUser != nil {
// for users logging through SSO flow but are having status as pending_invite
if existingUser.Status == types.UserStatusPendingInvite {
// capture old roles before overwriting with SSO roles
oldRoles := existingUser.Roles
// respect the role coming from the SSO
existingUser.Update("", user.Role)
existingUser.Update("", user.Role, user.Roles)
// activate the user
if err = module.activatePendingUser(ctx, existingUser); err != nil {
if err = module.activatePendingUser(ctx, existingUser, oldRoles); err != nil {
return nil, err
}
}
@@ -688,7 +836,7 @@ func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UU
}
func (module *Module) CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, passwd string) (*types.User, error) {
user, err := types.NewRootUser(name, email, organization.ID)
user, err := types.NewRootUser(name, email, organization.ID, []string{authtypes.SigNozAdminRoleName})
if err != nil {
return nil, err
}
@@ -750,20 +898,24 @@ func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
// this function restricts that only one non-deleted user email can exist for an org ID, if found more, it throws an error
func (module *Module) GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error) {
existingUsers, err := module.store.GetUsersByEmailAndOrgID(ctx, email, orgID)
existingStorableUsers, err := module.store.GetUsersByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
// filter out the deleted users
existingUsers = slices.DeleteFunc(existingUsers, func(user *types.User) bool { return user.ErrIfDeleted() != nil })
existingStorableUsers = slices.DeleteFunc(existingStorableUsers, func(user *types.StorableUser) bool { return user.ErrIfDeleted() != nil })
if len(existingUsers) > 1 {
if len(existingStorableUsers) > 1 {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "Multiple non-deleted users found for email %s in org_id: %s", email.StringValue(), orgID.StringValue())
}
if len(existingUsers) == 1 {
return existingUsers[0], nil
if len(existingStorableUsers) == 1 {
existingUser, err := module.GetByOrgIDAndUserID(ctx, existingStorableUsers[0].OrgID, existingStorableUsers[0].ID)
if err != nil {
return nil, err
}
return existingUser, nil
}
return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "No non-deleted user found with email %s in org_id: %s", email.StringValue(), orgID.StringValue())
@@ -773,7 +925,12 @@ func (module *Module) GetNonDeletedUserByEmailAndOrgID(ctx context.Context, emai
func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
if err := module.store.CreateUser(ctx, types.NewStorableUser(input)); err != nil {
return err
}
// create user_role junction entries
if err := module.createUserRoleEntries(ctx, input); err != nil {
return err
}
@@ -795,11 +952,27 @@ func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.U
return nil
}
func (module *Module) activatePendingUser(ctx context.Context, user *types.User) error {
err := module.authz.Grant(
func (module *Module) createUserRoleEntries(ctx context.Context, user *types.User) error {
if len(user.Roles) == 0 {
return nil
}
storableRoles, err := module.authz.ListByOrgIDAndNames(ctx, user.OrgID, user.Roles)
if err != nil {
return err
}
userRoles := authtypes.NewStorableUserRoles(user.ID, storableRoles)
return module.userRoleStore.CreateUserRoles(ctx, userRoles)
}
func (module *Module) activatePendingUser(ctx context.Context, user *types.User, oldRoles []string) error {
// use ModifyGrant to revoke old invite roles and grant new SSO roles
err := module.authz.ModifyGrant(
ctx,
user.OrgID,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
oldRoles,
user.Roles,
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
)
if err != nil {
@@ -809,10 +982,66 @@ func (module *Module) activatePendingUser(ctx context.Context, user *types.User)
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
err = module.store.UpdateUser(ctx, user.OrgID, user)
if err != nil {
return err
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.UpdateUser(ctx, user.OrgID, types.NewStorableUser(user)); err != nil {
return err
}
// delete old invite role entries and create new ones from SSO
if err := module.userRoleStore.DeleteUserRoles(ctx, user.ID); err != nil {
return err
}
return module.createUserRoleEntries(ctx, user)
})
}
func (module *Module) usersFromStorableUsersAndRolesMaps(storableUsers []*types.StorableUser, roles []*authtypes.Role, userIDToRoleIDsMap map[valuer.UUID][]valuer.UUID) []*types.User {
users := make([]*types.User, 0, len(storableUsers))
roleIDToRole := make(map[string]*authtypes.Role, len(roles))
for _, role := range roles {
roleIDToRole[role.ID.String()] = role
}
return nil
for _, user := range storableUsers {
roleIDs := userIDToRoleIDsMap[user.ID]
roleNames := make([]string, 0, len(roleIDs))
for _, rid := range roleIDs {
if role, ok := roleIDToRole[rid.String()]; ok {
roleNames = append(roleNames, role.Name)
}
}
account := types.NewUserFromStorable(user, roleNames)
users = append(users, account)
}
return users
}
func (m *Module) resolveRoleNamesForUser(ctx context.Context, userID valuer.UUID, orgID valuer.UUID) ([]string, error) {
storableUserRoles, err := m.userRoleStore.GetUserRolesByUserID(ctx, userID)
if err != nil {
return nil, err
}
roleIDs := make([]valuer.UUID, len(storableUserRoles))
for idx, sur := range storableUserRoles {
roleIDs[idx] = sur.RoleID
}
roles, err := m.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
if err != nil {
return nil, err
}
roleNames := make([]string, len(roles))
for idx, role := range roles {
roleNames[idx] = role.Name
}
return roleNames, nil
}

View File

@@ -15,31 +15,34 @@ import (
)
type service struct {
settings factory.ScopedProviderSettings
store types.UserStore
module user.Module
orgGetter organization.Getter
authz authz.AuthZ
config user.RootConfig
stopC chan struct{}
settings factory.ScopedProviderSettings
store types.UserStore
userRoleStore authtypes.UserRoleStore
module user.Module
orgGetter organization.Getter
authz authz.AuthZ
config user.RootConfig
stopC chan struct{}
}
func NewService(
providerSettings factory.ProviderSettings,
store types.UserStore,
userRoleStore authtypes.UserRoleStore,
module user.Module,
orgGetter organization.Getter,
authz authz.AuthZ,
config user.RootConfig,
) user.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
store: store,
module: module,
orgGetter: orgGetter,
authz: authz,
config: config,
stopC: make(chan struct{}),
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
store: store,
userRoleStore: userRoleStore,
module: module,
orgGetter: orgGetter,
authz: authz,
config: config,
stopC: make(chan struct{}),
}
}
@@ -77,59 +80,33 @@ func (s *service) Stop(ctx context.Context) error {
}
func (s *service) reconcile(ctx context.Context) error {
if !s.config.Org.ID.IsZero() {
return s.reconcileWithOrgID(ctx)
}
return s.reconcileByName(ctx)
}
func (s *service) reconcileWithOrgID(ctx context.Context) error {
org, err := s.orgGetter.Get(ctx, s.config.Org.ID)
org, resolvedByName, err := s.orgGetter.GetByIDOrName(ctx, s.config.Org.ID, s.config.Org.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err // something really went wrong
}
// org was not found using id check if we can find an org using name
existingOrgByName, nameErr := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if nameErr != nil && !errors.Ast(nameErr, errors.TypeNotFound) {
return nameErr // something really went wrong
}
// we found an org using name
if existingOrgByName != nil {
// the existing org has the same name as config but org id is different inform user with actionable message
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, existingOrgByName.ID.StringValue(), s.config.Org.ID.StringValue())
}
// default - we did not found any org using id and name both - create a new org
newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name)
_, err = s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
return s.reconcileRootUser(ctx, org.ID)
}
func (s *service) reconcileByName(ctx context.Context) error {
org, err := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
if s.config.Org.ID.IsZero() {
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
_, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name)
_, err = s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
if !s.config.Org.ID.IsZero() && resolvedByName {
// the existing org has the same name as config but org id is different; inform user with actionable message
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, org.ID.StringValue(), s.config.Org.ID.StringValue())
}
return s.reconcileRootUser(ctx, org.ID)
}
func (s *service) reconcileRootUser(ctx context.Context, orgID valuer.UUID) error {
existingRoot, err := s.store.GetRootUserByOrgID(ctx, orgID)
existingRoot, err := s.getRootUserByOrgID(ctx, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
@@ -148,29 +125,49 @@ func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID
}
if existingUser != nil {
oldRole := existingUser.Role
oldRoles := existingUser.Roles
existingUser.PromoteToRoot()
existingUser.PromoteToRoot() // this only sets the column is_root as true (permissions are managed by authz in next step)
existingUser.Roles = []string{authtypes.SigNozAdminRoleName}
// authz grant is idempotent and safe to retry, so do it before DB mutations
if err := s.authz.ModifyGrant(ctx,
orgID,
oldRoles,
[]string{authtypes.SigNozAdminRoleName},
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), orgID, nil),
); err != nil {
return err
}
// this is idempotent
if err := s.module.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return err
}
if oldRole != types.RoleAdmin {
if err := s.authz.ModifyGrant(ctx,
orgID,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(oldRole)},
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(types.RoleAdmin)},
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), orgID, nil),
); err != nil {
return err
}
// resolve the admin role ID for user_role entries
storableRoles, err := s.authz.ListByOrgIDAndNames(ctx, orgID, []string{authtypes.SigNozAdminRoleName})
if err != nil {
return err
}
return s.setPassword(ctx, existingUser.ID)
// wrap user_role updates and password in a transaction
return s.store.RunInTx(ctx, func(ctx context.Context) error {
if err := s.userRoleStore.DeleteUserRoles(ctx, existingUser.ID); err != nil {
return err
}
userRoles := authtypes.NewStorableUserRoles(existingUser.ID, storableRoles)
if err := s.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
return err
}
return s.setPassword(ctx, existingUser.ID)
})
}
// Create new root user
newUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID)
newUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID, []string{authtypes.SigNozAdminRoleName})
if err != nil {
return err
}
@@ -180,6 +177,7 @@ func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID
return err
}
// authz grants are handled inside CreateUser
return s.module.CreateUser(ctx, newUser, user.WithFactorPassword(factorPassword))
}
@@ -221,3 +219,12 @@ func (s *service) setPassword(ctx context.Context, userID valuer.UUID) error {
return nil
}
func (s *service) getRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
storableRoot, err := s.store.GetRootUserByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
return s.module.GetByOrgIDAndUserID(ctx, orgID, storableRoot.ID)
}

View File

@@ -39,7 +39,7 @@ func (store *store) CreatePassword(ctx context.Context, password *types.FactorPa
return nil
}
func (store *store) CreateUser(ctx context.Context, user *types.User) error {
func (store *store) CreateUser(ctx context.Context, user *types.StorableUser) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
@@ -52,8 +52,8 @@ func (store *store) CreateUser(ctx context.Context, user *types.User) error {
return nil
}
func (store *store) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*types.User, error) {
var users []*types.User
func (store *store) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*types.StorableUser, error) {
var users []*types.StorableUser
err := store.
sqlstore.
@@ -69,8 +69,8 @@ func (store *store) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]
return users, nil
}
func (store *store) GetUser(ctx context.Context, id valuer.UUID) (*types.User, error) {
user := new(types.User)
func (store *store) GetUser(ctx context.Context, id valuer.UUID) (*types.StorableUser, error) {
user := new(types.StorableUser)
err := store.
sqlstore.
@@ -86,8 +86,8 @@ func (store *store) GetUser(ctx context.Context, id valuer.UUID) (*types.User, e
return user, nil
}
func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.User, error) {
user := new(types.User)
func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableUser, error) {
user := new(types.StorableUser)
err := store.
sqlstore.
@@ -104,8 +104,8 @@ func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id v
return user, nil
}
func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*types.StorableUser, error) {
var users []*types.StorableUser
err := store.
sqlstore.
@@ -122,26 +122,7 @@ func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Em
return users, nil
}
func (store *store) GetActiveUsersByRoleAndOrgID(ctx context.Context, role types.Role, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&users).
Where("org_id = ?", orgID).
Where("role = ?", role).
Where("status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
}
return users, nil
}
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *types.StorableUser) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
@@ -162,8 +143,8 @@ func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *typ
return nil
}
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.GettableUser, error) {
users := []*types.User{}
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.StorableUser, error) {
users := []*types.StorableUser{}
err := store.
sqlstore.
@@ -247,7 +228,7 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
// delete user
_, err = tx.NewDelete().
Model(new(types.User)).
Model(new(types.StorableUser)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
@@ -332,7 +313,7 @@ func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string)
// soft delete user
now := time.Now()
_, err = tx.NewUpdate().
Model(new(types.User)).
Model(new(types.StorableUser)).
Set("status = ?", types.UserStatusDeleted).
Set("deleted_at = ?", now).
Set("updated_at = ?", now).
@@ -563,7 +544,7 @@ func (store *store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*type
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
user := new(types.User)
user := new(types.StorableUser)
count, err := store.
sqlstore.
@@ -580,7 +561,7 @@ func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64,
}
func (store *store) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error) {
user := new(types.User)
user := new(types.StorableUser)
var results []struct {
Status valuer.String `bun:"status"`
Count int64 `bun:"count"`
@@ -633,8 +614,8 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
})
}
func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
user := new(types.User)
func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.StorableUser, error) {
user := new(types.StorableUser)
err := store.
sqlstore.
BunDBCtx(ctx).
@@ -649,8 +630,8 @@ func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (
return user, nil
}
func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
users := []*types.User{}
func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.StorableUser, error) {
users := []*types.StorableUser{}
err := store.
sqlstore.
BunDB().
@@ -666,15 +647,15 @@ func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.
return users, nil
}
func (store *store) GetUserByResetPasswordToken(ctx context.Context, token string) (*types.User, error) {
user := new(types.User)
func (store *store) GetUserByResetPasswordToken(ctx context.Context, token string) (*types.StorableUser, error) {
user := new(types.StorableUser)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Join(`JOIN factor_password ON factor_password.user_id = "user".id`).
Join(`JOIN factor_password ON factor_password.user_id = "users".id`).
Join("JOIN reset_password_token ON reset_password_token.password_id = factor_password.id").
Where("reset_password_token.token = ?", token).
Scan(ctx)
@@ -685,8 +666,8 @@ func (store *store) GetUserByResetPasswordToken(ctx context.Context, token strin
return user, nil
}
func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, emails []string, statuses []string) ([]*types.User, error) {
users := []*types.User{}
func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, emails []string, statuses []string) ([]*types.StorableUser, error) {
users := []*types.StorableUser{}
err := store.
sqlstore.
@@ -703,3 +684,20 @@ func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID
return users, nil
}
func (store *store) GetActiveUsersByRoleNameAndOrgID(ctx context.Context, roleName string, orgID valuer.UUID) ([]*types.StorableUser, error) {
var users []*types.StorableUser
err := store.sqlstore.BunDBCtx(ctx).NewSelect().
Model(&users).
Join("JOIN user_role ON user_role.user_id = users.id").
Join("JOIN role ON role.id = user_role.role_id").
Where("users.org_id = ?", orgID).
Where("role.name = ?", roleName).
Where("users.status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
}
return users, nil
}

View File

@@ -0,0 +1,62 @@
package impluser
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
type userRoleStore struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewUserRoleStore(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings) authtypes.UserRoleStore {
return &userRoleStore{sqlstore: sqlstore, settings: settings}
}
func (store *userRoleStore) ListUserRolesByOrgIDAndUserIDs(ctx context.Context, orgID valuer.UUID, userIDs []valuer.UUID) ([]*authtypes.StorableUserRole, error) {
storableUserRoles := make([]*authtypes.StorableUserRole, 0)
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&storableUserRoles).
Join("JOIN users").
JoinOn("users.id = user_role.user_id").
Where("users.org_id = ?", orgID).Where("users.id IN (?)", bun.In(userIDs)).Scan(ctx)
if err != nil {
return nil, err
}
return storableUserRoles, nil
}
func (store *userRoleStore) CreateUserRoles(ctx context.Context, userRoles []*authtypes.StorableUserRole) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewInsert().Model(&userRoles).Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, authtypes.ErrCodeUserRoleAlreadyExists, "duplicate role assignments for service account")
}
return nil
}
func (store *userRoleStore) DeleteUserRoles(ctx context.Context, userID valuer.UUID) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().Model(new(authtypes.StorableUserRole)).Where("user_id = ?", userID).Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *userRoleStore) GetUserRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*authtypes.StorableUserRole, error) {
storableUserRoles := make([]*authtypes.StorableUserRole, 0)
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&storableUserRoles).Where("user_id = ?", userID).Scan(ctx)
if err != nil {
return nil, err
}
return storableUserRoles, nil
}

View File

@@ -10,6 +10,12 @@ import (
)
type Module interface {
// Gets user by org id and user id, this includes the roles resolution
GetByOrgIDAndUserID(ctx context.Context, orgID, userID valuer.UUID) (*types.User, error)
// Lists all the users by org id, includes roles resolution
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error)
// Creates the organization and the first user of that organization.
CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, password string) (*types.User, error)
@@ -21,7 +27,7 @@ type Module interface {
// Get or Create a reset password token for a user. If the password does not exist, a new one is randomly generated and inserted. The function
// is idempotent and can be called multiple times.
GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error)
GetOrCreateResetPasswordToken(ctx context.Context, orgID, userID valuer.UUID) (*types.ResetPasswordToken, error)
// Updates password of a user using a reset password token. It also deletes all reset password tokens for the user.
// This is used to reset the password of a user when they forget their password.
@@ -58,22 +64,13 @@ type Module interface {
}
type Getter interface {
// Get root user by org id.
GetRootUserByOrgID(context.Context, valuer.UUID) (*types.User, error)
// Get gets the users based on the given id
ListByOrgID(context.Context, valuer.UUID) ([]*types.User, error)
// Get users by email.
GetUsersByEmail(context.Context, valuer.Email) ([]*types.User, error)
// Get user by orgID and id.
GetByOrgIDAndID(context.Context, valuer.UUID, valuer.UUID) (*types.User, error)
// Get user by id.
Get(context.Context, valuer.UUID) (*types.User, error)
// List users by email and org ids.
// List users by email and org ids. This does not includes roles resolution as this is only used for session context
ListUsersByEmailAndOrgIDs(context.Context, valuer.Email, []valuer.UUID) ([]*types.User, error)
// Count users by org id.

View File

@@ -3945,67 +3945,53 @@ func (r *ClickHouseReader) GetLogAttributeKeys(ctx context.Context, req *v3.Filt
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetLogAttributeKeys",
})
var query string
var err error
var rows driver.Rows
var response v3.FilterAttributeKeyResponse
attributeKeysTable := r.logsDB + "." + r.logsAttributeKeys
resourceAttrKeysTable := r.logsDB + "." + r.logsResourceKeys
var tagTypes []string
var tables []string
switch req.TagType {
case v3.TagTypeTag:
tables, tagTypes = []string{attributeKeysTable}, []string{"tag"}
case v3.TagTypeResource:
tables, tagTypes = []string{resourceAttrKeysTable}, []string{"resource"}
case "":
tables, tagTypes = []string{attributeKeysTable, resourceAttrKeysTable}, []string{"tag", "resource"}
default:
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "unsupported tag type: %s", req.TagType)
tagTypeFilter := `tag_type != 'logfield'`
if req.TagType != "" {
tagTypeFilter = fmt.Sprintf(`tag_type != 'logfield' and tag_type = '%s'`, req.TagType)
}
if len(req.SearchText) != 0 {
query = fmt.Sprintf("select distinct tag_key, tag_type, tag_data_type from %s.%s where %s and tag_key ILIKE $1 limit $2", r.logsDB, r.logsTagAttributeTableV2, tagTypeFilter)
rows, err = r.db.Query(ctx, query, fmt.Sprintf("%%%s%%", req.SearchText), req.Limit)
} else {
query = fmt.Sprintf("select distinct tag_key, tag_type, tag_data_type from %s.%s where %s limit $1", r.logsDB, r.logsTagAttributeTableV2, tagTypeFilter)
rows, err = r.db.Query(ctx, query, req.Limit)
}
if err != nil {
r.logger.Error("Error while executing query", "error", err)
return nil, fmt.Errorf("error while executing query: %s", err.Error())
}
defer rows.Close()
statements := []model.ShowCreateTableStatement{}
stmtQuery := fmt.Sprintf("SHOW CREATE TABLE %s.%s", r.logsDB, r.logsLocalTableName)
if err := r.db.Select(ctx, &statements, stmtQuery); err != nil {
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while fetching logs schema")
query = fmt.Sprintf("SHOW CREATE TABLE %s.%s", r.logsDB, r.logsLocalTableName)
err = r.db.Select(ctx, &statements, query)
if err != nil {
return nil, fmt.Errorf("error while fetching logs schema: %s", err.Error())
}
for i, table := range tables {
tagType := tagTypes[i]
var query string
if len(req.SearchText) != 0 {
query = fmt.Sprintf("select distinct name, lower(datatype) from %s where name ILIKE $1 limit $2", table)
} else {
query = fmt.Sprintf("select distinct name, lower(datatype) from %s limit $1", table)
var attributeKey string
var attributeDataType string
var tagType string
for rows.Next() {
if err := rows.Scan(&attributeKey, &tagType, &attributeDataType); err != nil {
return nil, fmt.Errorf("error while scanning rows: %s", err.Error())
}
var rows driver.Rows
var err error
if len(req.SearchText) != 0 {
rows, err = r.db.Query(ctx, query, fmt.Sprintf("%%%s%%", req.SearchText), req.Limit)
} else {
rows, err = r.db.Query(ctx, query, req.Limit)
}
if err != nil {
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while executing query")
key := v3.AttributeKey{
Key: attributeKey,
DataType: v3.AttributeKeyDataType(attributeDataType),
Type: v3.AttributeKeyType(tagType),
IsColumn: isColumn(statements[0].Statement, tagType, attributeKey, attributeDataType),
}
for rows.Next() {
var keyName string
var datatype string
if err := rows.Scan(&keyName, &datatype); err != nil {
rows.Close()
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while scanning rows")
}
key := v3.AttributeKey{
Key: keyName,
DataType: v3.AttributeKeyDataType(datatype),
Type: v3.AttributeKeyType(tagType),
IsColumn: isColumn(statements[0].Statement, tagType, keyName, datatype),
}
response.AttributeKeys = append(response.AttributeKeys, key)
}
rows.Close()
response.AttributeKeys = append(response.AttributeKeys, key)
}
// add other attributes only when the tagType is not specified

View File

@@ -48,9 +48,11 @@ func TestNewHandlers(t *testing.T) {
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, flagger)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil)

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/apdex"
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
@@ -89,10 +90,12 @@ func NewModules(
config Config,
dashboard dashboard.Module,
userGetter user.Getter,
userRoleStore authtypes.UserRoleStore,
flagger flagger.Flagger,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User)
user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), userRoleStore, tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, flagger)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{
@@ -108,7 +111,7 @@ func NewModules(
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter, authz),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),

View File

@@ -47,9 +47,11 @@ func TestNewModules(t *testing.T) {
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, flagger)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -82,7 +82,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
reflector.SpecSchema().SetTitle("SigNoz")
reflector.SpecSchema().SetDescription("OpenTelemetry-Native Logs, Metrics and Traces in a single pane")
reflector.SpecSchema().SetAPIKeySecurity(authtypes.IdentNProviderAPIkey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys")
reflector.SpecSchema().SetAPIKeySecurity(authtypes.IdentNProviderAPIKey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys")
reflector.SpecSchema().SetHTTPBearerTokenSecurity(authtypes.IdentNProviderTokenizer.StringValue(), "Tokenizer", "Tokens generated by the tokenizer")
collector := handler.NewOpenAPICollector(reflector)

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/global/signozglobal"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/identn/apikeyidentn"
"github.com/SigNoz/signoz/pkg/identn/impersonationidentn"
"github.com/SigNoz/signoz/pkg/identn/tokenizeridentn"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -177,6 +178,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDeprecateUserInviteFactory(sqlstore, sqlschema),
sqlmigration.NewUpdateCloudIntegrationUniqueIndexFactory(sqlstore, sqlschema),
sqlmigration.NewUpdatePlannedMaintenanceRuleFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserRoleFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserRoleAuthzFactory(sqlstore),
)
}
@@ -242,7 +245,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
)
}
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, global global.Global, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
@@ -252,7 +255,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
implsession.NewHandler(modules.Session),
implauthdomain.NewHandler(modules.AuthDomain),
implpreference.NewHandler(modules.Preference),
signozglobal.NewHandler(global),
handlers.Global,
implpromote.NewHandler(modules.Promote),
handlers.FlaggerHandler,
modules.Dashboard,
@@ -276,16 +279,17 @@ func NewTokenizerProviderFactories(cache cache.Cache, sqlstore sqlstore.SQLStore
)
}
func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
return factory.MustNewNamedMap(
impersonationidentn.NewFactory(orgGetter, userGetter, userConfig),
tokenizeridentn.NewFactory(tokenizer),
apikeyidentn.NewFactory(sqlstore),
)
}
func NewGlobalProviderFactories() factory.NamedMap[factory.ProviderFactory[global.Global, global.Config]] {
func NewGlobalProviderFactories(identNConfig identn.Config) factory.NamedMap[factory.ProviderFactory[global.Global, global.Config]] {
return factory.MustNewNamedMap(
signozglobal.NewFactory(),
signozglobal.NewFactory(identNConfig),
)
}

View File

@@ -1,13 +1,11 @@
package signoz
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -77,12 +75,7 @@ func TestNewProviderFactories(t *testing.T) {
})
assert.NotPanics(t, func() {
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
if err != nil {
panic(err)
}
userGetter := impluser.NewGetter(impluser.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), instrumentationtest.New().ToProviderSettings()), flagger)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), instrumentationtest.New().ToProviderSettings()))
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherEqual)
NewStatsReporterProviderFactories(telemetryStore, []statsreporter.StatsCollector{}, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
@@ -92,7 +85,6 @@ func TestNewProviderFactories(t *testing.T) {
NewAPIServerProviderFactories(
implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil),
nil,
nil,
Modules{},
Handlers{},
)

View File

@@ -281,8 +281,14 @@ func New(
return nil, err
}
// Initialize user store
userStore := impluser.NewStore(sqlstore, providerSettings)
// Initialize user role store
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
// Initialize user getter
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
userGetter := impluser.NewGetter(userStore)
licensingProviderFactory := licenseProviderFactory(sqlstore, zeus, orgGetter, analytics)
licensing, err := licensingProviderFactory.New(
@@ -382,7 +388,7 @@ func New(
ctx,
providerSettings,
config.Global,
NewGlobalProviderFactories(),
NewGlobalProviderFactories(config.IdentN),
"signoz",
)
if err != nil {
@@ -390,21 +396,16 @@ func New(
}
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, flagger)
// Initialize identN resolver
identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer)
identNs := []identn.IdentN{}
for _, identNFactory := range identNFactories.GetInOrder() {
identN, err := identNFactory.New(ctx, providerSettings, config.IdentN)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer, orgGetter, userGetter, config.User)
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
if err != nil {
return nil, err
}
identNResolver := identn.NewIdentNResolver(providerSettings, identNs...)
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root)
userService := impluser.NewService(providerSettings, userStore, userRoleStore, modules.User, orgGetter, authz, config.User.Root)
// Initialize the querier handler via callback (allows EE to decorate with anomaly detection)
querierHandler := querierHandlerCallback(providerSettings, querier, analytics)
@@ -417,7 +418,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, global, modules, handlers),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers),
"signoz",
)
if err != nil {

View File

@@ -2,6 +2,7 @@ package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
@@ -16,12 +17,12 @@ type funnel struct {
types.Identifiable // funnel id
types.TimeAuditable
types.UserAuditable
Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name
Description string `json:"description" bun:"description,type:text"` // funnel description
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
Steps []funnelStep `json:"steps" bun:"steps,type:text,notnull"`
Tags string `json:"tags" bun:"tags,type:text"`
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name
Description string `json:"description" bun:"description,type:text"` // funnel description
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
Steps []funnelStep `json:"steps" bun:"steps,type:text,notnull"`
Tags string `json:"tags" bun:"tags,type:text"`
CreatedByUser *types.StorableUser `json:"user" bun:"rel:belongs-to,join:created_by=id"`
}
type funnelStep struct {

View File

@@ -0,0 +1,197 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
var (
userRoleToSigNozManagedRoleMap = map[string]string{
"ADMIN": "signoz-admin",
"EDITOR": "signoz-editor",
"VIEWER": "signoz-viewer",
}
)
type userRow struct {
ID string `bun:"id"`
Role string `bun:"role"`
OrgID string `bun:"org_id"`
}
type roleRow struct {
ID string `bun:"id"`
Name string `bun:"name"`
OrgID string `bun:"org_id"`
}
type orgRoleKey struct {
OrgID string
RoleName string
}
type addUserRole struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
type userRoleRow struct {
bun.BaseModel `bun:"table:user_role"`
types.Identifiable
UserID string `bun:"user_id"`
RoleID string `bun:"role_id"`
types.TimeAuditable
}
func NewAddUserRoleFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_role"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserRole{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addUserRole) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addUserRole) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "user_role",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "role_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("user_id"),
ReferencedTableName: sqlschema.TableName("users"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("role_id"),
ReferencedTableName: sqlschema.TableName("role"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs := migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "user_role",
ColumnNames: []sqlschema.ColumnName{"user_id", "role_id"},
},
)
sqls = append(sqls, indexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// fill the new user_role table for existing users
var users []userRow
err = tx.NewSelect().TableExpr("users").ColumnExpr("id, role, org_id").Scan(ctx, &users)
if err != nil {
return err
}
if len(users) == 0 {
return tx.Commit()
}
orgIDs := make(map[string]struct{})
for _, u := range users {
orgIDs[u.OrgID] = struct{}{}
}
orgIDList := make([]string, 0, len(orgIDs))
for oid := range orgIDs {
orgIDList = append(orgIDList, oid)
}
var roles []roleRow
err = tx.NewSelect().TableExpr("role").ColumnExpr("id, name, org_id").Where("org_id IN (?)", bun.In(orgIDList)).Scan(ctx, &roles)
if err != nil {
return err
}
roleMap := make(map[orgRoleKey]string)
for _, r := range roles {
roleMap[orgRoleKey{OrgID: r.OrgID, RoleName: r.Name}] = r.ID
}
now := time.Now()
userRoles := make([]*userRoleRow, 0, len(users))
for _, u := range users {
managedRoleName, ok := userRoleToSigNozManagedRoleMap[u.Role]
if !ok {
managedRoleName = "signoz-viewer" // fallback
}
roleID, ok := roleMap[orgRoleKey{OrgID: u.OrgID, RoleName: managedRoleName}]
if !ok {
continue // user needs to get access again
}
userRoles = append(userRoles, &userRoleRow{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
UserID: u.ID,
RoleID: roleID,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
})
}
if len(userRoles) > 0 {
if _, err := tx.NewInsert().Model(&userRoles).Exec(ctx); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *addUserRole) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,156 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addUserRoleAuthz struct {
sqlstore sqlstore.SQLStore
}
func NewAddUserRoleAuthzFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_role_authz"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserRoleAuthz{sqlstore: sqlstore}, nil
})
}
func (migration *addUserRoleAuthz) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addUserRoleAuthz) 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
}
type userRoleTuple struct {
UserID string
OrgID string
RoleName string
}
rows, err := tx.QueryContext(ctx, `
SELECT u.id, u.org_id, r.name
FROM users u
JOIN user_role ur ON ur.user_id = u.id
JOIN role r ON r.id = ur.role_id
WHERE u.status != 'deleted'
`)
if err != nil {
if err == sql.ErrNoRows {
return tx.Commit()
}
return err
}
defer rows.Close()
tuples := make([]userRoleTuple, 0)
for rows.Next() {
var t userRoleTuple
if err := rows.Scan(&t.UserID, &t.OrgID, &t.RoleName); err != nil {
return err
}
tuples = append(tuples, t)
}
if err := rows.Err(); err != nil {
return err
}
entropy := ulid.DefaultEntropy()
for _, t := range tuples {
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + t.OrgID + "/role/" + t.RoleName
userID := "organization/" + t.OrgID + "/user/" + t.UserID
if migration.sqlstore.BunDB().Dialect().Name() == dialect.PG {
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, "role", objectID, "assignee", "user:"+userID, "user", 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, "role", objectID, "assignee", "user:"+userID, "TUPLE_OPERATION_WRITE", 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, "role", objectID, "assignee", "user", userID, "", "user", 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, "role", objectID, "assignee", "user", userID, "", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addUserRoleAuthz) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -34,7 +34,7 @@ func (store *store) Create(ctx context.Context, token *authtypes.StorableToken)
}
func (store *store) GetIdentityByUserID(ctx context.Context, userID valuer.UUID) (*authtypes.Identity, error) {
user := new(types.User)
user := new(types.StorableUser)
err := store.
sqlstore.

View File

@@ -128,7 +128,7 @@ func (typ *Identity) ToClaims() Claims {
type AuthNStore interface {
// Get user and factor password by email and orgID.
GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error)
GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.StorableUser, *types.FactorPassword, error)
// Get org domain from id.
GetAuthDomainFromID(ctx context.Context, domainID valuer.UUID) (*AuthDomain, error)

View File

@@ -3,10 +3,11 @@ package authtypes
import "github.com/SigNoz/signoz/pkg/valuer"
var (
IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")}
IdentNProviderAPIkey = IdentNProvider{valuer.NewString("api_key")}
IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")}
IdentNProviderInternal = IdentNProvider{valuer.NewString("internal")}
IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")}
IdentNProviderAPIKey = IdentNProvider{valuer.NewString("api_key")}
IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")}
IdentNProviderInternal = IdentNProvider{valuer.NewString("internal")}
IdentNProviderImpersonation = IdentNProvider{valuer.NewString("impersonation")}
)
type IdentNProvider struct{ valuer.String }

View File

@@ -83,44 +83,56 @@ func (typ *RoleMapping) UnmarshalJSON(data []byte) error {
return nil
}
func (roleMapping *RoleMapping) NewRoleFromCallbackIdentity(callbackIdentity *CallbackIdentity) types.Role {
func (roleMapping *RoleMapping) ManagedRolesFromCallbackIdentity(callbackIdentity *CallbackIdentity) []string {
if roleMapping == nil {
return types.RoleViewer
return []string{SigNozViewerRoleName}
}
if roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
if role, err := types.NewRole(strings.ToUpper(callbackIdentity.Role)); err == nil {
return role
if managedRole := resolveToManagedRole(callbackIdentity.Role); managedRole != "" {
return []string{managedRole}
}
}
if len(roleMapping.GroupMappings) > 0 && len(callbackIdentity.Groups) > 0 {
highestRole := types.RoleViewer
found := false
seen := make(map[string]struct{})
var roles []string
for _, group := range callbackIdentity.Groups {
if mappedRole, exists := roleMapping.GroupMappings[group]; exists {
found = true
if role, err := types.NewRole(strings.ToUpper(mappedRole)); err == nil {
if compareRoles(role, highestRole) > 0 {
highestRole = role
managedRole := resolveToManagedRole(mappedRole)
if managedRole != "" {
if _, ok := seen[managedRole]; !ok {
seen[managedRole] = struct{}{}
roles = append(roles, managedRole)
}
}
}
}
if found {
return highestRole
if len(roles) > 0 {
return roles
}
}
if roleMapping.DefaultRole != "" {
if role, err := types.NewRole(strings.ToUpper(roleMapping.DefaultRole)); err == nil {
return role
if managedRole := resolveToManagedRole(roleMapping.DefaultRole); managedRole != "" {
return []string{managedRole}
}
}
return types.RoleViewer
return []string{SigNozViewerRoleName}
}
// for backward compatibility in API responses
func HighestLegacyRoleFromManagedRoles(managedRoles []string) types.Role {
highest := types.RoleViewer
for _, name := range managedRoles {
for legacyRole, managedName := range ExistingRoleToSigNozManagedRoleMap {
if managedName == name && compareRoles(legacyRole, highest) > 0 {
highest = legacyRole
}
}
}
return highest
}
func compareRoles(a, b types.Role) int {
@@ -131,3 +143,13 @@ func compareRoles(a, b types.Role) int {
}
return order[a] - order[b]
}
func resolveToManagedRole(role string) string {
// backward compatible legacy role (ADMIN -> signoz-admin) useful in case of SSO
if legacyRole, err := types.NewRole(strings.ToUpper(role)); err == nil {
return MustGetSigNozManagedRoleFromExistingRole(legacyRole)
}
// if it's not a valid legacy role, return empty to signal unrecognized
return ""
}

View File

@@ -0,0 +1,104 @@
package authtypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodeUserRoleAlreadyExists = errors.MustNewCode("user_role_already_exists")
)
type StorableUserRole struct {
bun.BaseModel `bun:"table:user_role,alias:user_role"`
types.Identifiable
UserID valuer.UUID `bun:"user_id"`
RoleID valuer.UUID `bun:"role_id"`
types.TimeAuditable
}
func newStorableUserRole(userID valuer.UUID, roleID valuer.UUID) *StorableUserRole {
return &StorableUserRole{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
UserID: userID,
RoleID: roleID,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
}
func NewStorableUserRoles(userID valuer.UUID, roles []*Role) []*StorableUserRole {
storableUserRoles := make([]*StorableUserRole, len(roles))
for idx, role := range roles {
storableUserRoles[idx] = newStorableUserRole(userID, role.ID)
}
return storableUserRoles
}
func GetUserIDToRoleIDsMappingAndUniqueRoles(storableUserRoles []*StorableUserRole) (map[valuer.UUID][]valuer.UUID, []valuer.UUID) {
userIDRoles := make(map[valuer.UUID][]valuer.UUID)
uniqueRoleIDSet := make(map[valuer.UUID]struct{})
for _, userRole := range storableUserRoles {
userID := userRole.UserID
if _, ok := userIDRoles[userID]; !ok {
userIDRoles[userID] = make([]valuer.UUID, 0)
}
roleUUID := userRole.RoleID
userIDRoles[userID] = append(userIDRoles[userID], roleUUID)
uniqueRoleIDSet[userRole.RoleID] = struct{}{}
}
roleIDs := make([]valuer.UUID, 0, len(uniqueRoleIDSet))
for rid := range uniqueRoleIDSet {
roleIDs = append(roleIDs, rid)
}
return userIDRoles, roleIDs
}
func NewRoleNamesFromStorableUserRoles(storableUserRoles []*StorableUserRole, roles []*Role) ([]string, error) {
roleIDToName := make(map[valuer.UUID]string, len(roles))
for _, role := range roles {
roleIDToName[role.ID] = role.Name
}
names := make([]string, 0, len(storableUserRoles))
for _, storableUserRole := range storableUserRoles {
roleName, ok := roleIDToName[storableUserRole.RoleID]
if !ok {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "role id %s not found in provided roles", storableUserRole.RoleID)
}
names = append(names, roleName)
}
return names, nil
}
type UserRoleStore interface {
// create user roles in bulk
CreateUserRoles(ctx context.Context, userRoles []*StorableUserRole) error
// get user roles by user id
GetUserRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*StorableUserRole, error)
// list all user_role entries for
ListUserRolesByOrgIDAndUserIDs(ctx context.Context, orgID valuer.UUID, userIDs []valuer.UUID) ([]*StorableUserRole, error)
// delete user role entries by user id
DeleteUserRoles(ctx context.Context, userID valuer.UUID) error
}

View File

@@ -39,15 +39,15 @@ type OrgUserAPIKey struct {
}
type UserWithAPIKey struct {
*User `bun:",extend"`
APIKeys []*StorableAPIKeyUser `bun:"rel:has-many,join:id=user_id"`
*StorableUser `bun:",extend"`
APIKeys []*StorableAPIKeyUser `bun:"rel:has-many,join:id=user_id"`
}
type StorableAPIKeyUser struct {
StorableAPIKey `bun:",extend"`
CreatedByUser *User `json:"createdByUser" bun:"created_by_user,rel:belongs-to,join:created_by=id"`
UpdatedByUser *User `json:"updatedByUser" bun:"updated_by_user,rel:belongs-to,join:updated_by=id"`
CreatedByUser *StorableUser `json:"createdByUser" bun:"created_by_user,rel:belongs-to,join:created_by=id"`
UpdatedByUser *StorableUser `json:"updatedByUser" bun:"updated_by_user,rel:belongs-to,join:updated_by=id"`
}
type StorableAPIKey struct {
@@ -138,7 +138,7 @@ func NewGettableAPIKeyFromStorableAPIKey(storableAPIKey *StorableAPIKeyUser) *Ge
LastUsed: lastUsed,
Revoked: storableAPIKey.Revoked,
UserID: storableAPIKey.UserID.String(),
CreatedByUser: storableAPIKey.CreatedByUser,
UpdatedByUser: storableAPIKey.UpdatedByUser,
CreatedByUser: NewUserFromStorable(storableAPIKey.CreatedByUser, make([]string, 0)), // factor api key will be removed
UpdatedByUser: NewUserFromStorable(storableAPIKey.UpdatedByUser, make([]string, 0)), // factor api key will be removed
}
}

View File

@@ -1,15 +0,0 @@
package types
import "net/url"
type GettableGlobalConfig struct {
ExternalURL string `json:"external_url"`
IngestionURL string `json:"ingestion_url"`
}
func NewGettableGlobalConfig(externalURL, ingestionURL *url.URL) *GettableGlobalConfig {
return &GettableGlobalConfig{
ExternalURL: externalURL.String(),
IngestionURL: ingestionURL.String(),
}
}

View File

@@ -0,0 +1,13 @@
package globaltypes
type Config struct {
Endpoint
IdentN IdentNConfig `json:"identN"`
}
func NewConfig(endpoint Endpoint, identN IdentNConfig) *Config {
return &Config{
Endpoint: endpoint,
IdentN: identN,
}
}

View File

@@ -0,0 +1,13 @@
package globaltypes
type Endpoint struct {
ExternalURL string `json:"external_url"`
IngestionURL string `json:"ingestion_url"`
}
func NewEndpoint(externalURL, ingestionURL string) Endpoint {
return Endpoint{
ExternalURL: externalURL,
IngestionURL: ingestionURL,
}
}

View File

@@ -0,0 +1,27 @@
package globaltypes
type IdentNConfig struct {
Tokenizer TokenizerConfig `json:"tokenizer"`
APIKey APIKeyConfig `json:"apikey"`
Impersonation ImpersonationConfig `json:"impersonation"`
}
type TokenizerConfig struct {
Enabled bool `json:"enabled"`
}
type APIKeyConfig struct {
Enabled bool `json:"enabled"`
}
type ImpersonationConfig struct {
Enabled bool `json:"enabled"`
}
func NewIdentNConfig(tokenizer TokenizerConfig, apiKey APIKeyConfig, impersonation ImpersonationConfig) IdentNConfig {
return IdentNConfig{
Tokenizer: tokenizer,
APIKey: apiKey,
Impersonation: impersonation,
}
}

View File

@@ -25,6 +25,7 @@ type Invite struct {
Email valuer.Email `bun:"email,type:text" json:"email"`
Token string `bun:"token,type:text" json:"token"`
Role Role `bun:"role,type:text" json:"role"`
Roles []string `bun:"roles,type:text" json:"roles"`
OrgID valuer.UUID `bun:"org_id,type:text" json:"orgId"`
InviteLink string `bun:"-" json:"inviteLink"`
@@ -50,6 +51,7 @@ type PostableInvite struct {
Name string `json:"name"`
Email valuer.Email `json:"email"`
Role Role `json:"role"`
Roles []string `json:"roles"`
FrontendBaseUrl string `json:"frontendBaseUrl"`
}
@@ -83,7 +85,7 @@ type GettableCreateInviteResponse struct {
InviteToken string `json:"token"`
}
func NewInvite(name string, role Role, orgID valuer.UUID, email valuer.Email) (*Invite, error) {
func NewInvite(name string, role Role, roles []string, orgID valuer.UUID, email valuer.Email) (*Invite, error) {
invite := &Invite{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
@@ -92,6 +94,7 @@ func NewInvite(name string, role Role, orgID valuer.UUID, email valuer.Email) (*
Email: email,
Token: valuer.GenerateUUID().String(),
Role: role,
Roles: roles,
OrgID: orgID,
TimeAuditable: TimeAuditable{
CreatedAt: time.Now(),

View File

@@ -18,12 +18,12 @@ type StorableFunnel struct {
types.TimeAuditable
types.UserAuditable
bun.BaseModel `bun:"table:trace_funnel"`
Name string `json:"funnel_name" bun:"name,type:text,notnull"`
Description string `json:"description" bun:"description,type:text"`
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
Steps []*FunnelStep `json:"steps" bun:"steps,type:text,notnull"`
Tags string `json:"tags" bun:"tags,type:text"`
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
Name string `json:"funnel_name" bun:"name,type:text,notnull"`
Description string `json:"description" bun:"description,type:text"`
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
Steps []*FunnelStep `json:"steps" bun:"steps,type:text,notnull"`
Tags string `json:"tags" bun:"tags,type:text"`
CreatedByUser *types.StorableUser `json:"user" bun:"rel:belongs-to,join:created_by=id"`
}
type FunnelStep struct {

View File

@@ -443,7 +443,7 @@ func TestConstructFunnelResponse(t *testing.T) {
},
Name: "test-funnel",
OrgID: orgID,
CreatedByUser: &types.User{
CreatedByUser: &types.StorableUser{
Identifiable: types.Identifiable{
ID: userID,
},

View File

@@ -33,10 +33,21 @@ var (
ValidUserStatus = []valuer.String{UserStatusPendingInvite, UserStatusActive, UserStatusDeleted}
)
type GettableUser = User
type User struct {
bun.BaseModel `bun:"table:users"`
Identifiable
DisplayName string `json:"displayName"`
Email valuer.Email `json:"email"`
Role Role `json:"role"`
Roles []string `json:"roles"`
OrgID valuer.UUID `json:"orgId"`
IsRoot bool `json:"isRoot"`
Status valuer.String `json:"status"`
DeletedAt time.Time `json:"-"`
TimeAuditable
}
type StorableUser struct {
bun.BaseModel `bun:"table:users,alias:users"`
Identifiable
DisplayName string `bun:"display_name" json:"displayName"`
@@ -57,7 +68,59 @@ type PostableRegisterOrgAndAdmin struct {
OrgName string `json:"orgName"`
}
func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUID, status valuer.String) (*User, error) {
func NewStorableUser(user *User) *StorableUser {
if user == nil {
return nil
}
return &StorableUser{
Identifiable: user.Identifiable,
DisplayName: user.DisplayName,
Email: user.Email,
Role: user.Role,
OrgID: user.OrgID,
IsRoot: user.IsRoot,
Status: user.Status,
DeletedAt: user.DeletedAt,
TimeAuditable: user.TimeAuditable,
}
}
func NewUserFromStorable(storableUser *StorableUser, roleNames []string) *User {
if storableUser == nil {
return nil
}
return &User{
Identifiable: storableUser.Identifiable,
DisplayName: storableUser.DisplayName,
Email: storableUser.Email,
Role: storableUser.Role,
Roles: roleNames,
OrgID: storableUser.OrgID,
IsRoot: storableUser.IsRoot,
Status: storableUser.Status,
DeletedAt: storableUser.DeletedAt,
TimeAuditable: storableUser.TimeAuditable,
}
}
// func NewUsersFromStorables(storableUsers []*StorableUser) []*User {
// users := make([]*User, len(storableUsers))
// for i, s := range storableUsers {
// users[i] = NewUserFromStorable(s)
// }
// return users
// }
func NewStorableUsers(users []*User) []*StorableUser {
storableUsers := make([]*StorableUser, len(users))
for i, u := range users {
storableUsers[i] = NewStorableUser(u)
}
return storableUsers
}
func NewUser(displayName string, email valuer.Email, role Role, roles []string, orgID valuer.UUID, status valuer.String) (*User, error) {
if email.IsZero() {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
}
@@ -81,6 +144,7 @@ func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUI
DisplayName: displayName,
Email: email,
Role: role,
Roles: roles,
OrgID: orgID,
IsRoot: false,
Status: status,
@@ -91,7 +155,7 @@ func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUI
}, nil
}
func NewRootUser(displayName string, email valuer.Email, orgID valuer.UUID) (*User, error) {
func NewRootUser(displayName string, email valuer.Email, orgID valuer.UUID, roleNames []string) (*User, error) {
if email.IsZero() {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
}
@@ -107,6 +171,7 @@ func NewRootUser(displayName string, email valuer.Email, orgID valuer.UUID) (*Us
DisplayName: displayName,
Email: email,
Role: RoleAdmin,
Roles: roleNames,
OrgID: orgID,
IsRoot: true,
Status: UserStatusActive,
@@ -119,13 +184,12 @@ func NewRootUser(displayName string, email valuer.Email, orgID valuer.UUID) (*Us
// Update applies mutable fields from the input to the user. Immutable fields
// (email, is_root, org_id, id) are preserved. Only non-zero input fields are applied.
func (u *User) Update(displayName string, role Role) {
func (u *User) Update(displayName string, role Role, roles []string) {
if displayName != "" {
u.DisplayName = displayName
}
if role != "" {
u.Role = role
}
u.Role = role
u.Roles = roles
u.UpdatedAt = time.Now()
}
@@ -168,6 +232,15 @@ func (u *User) ErrIfRoot() error {
return nil
}
// ErrIfRoot returns an error if the user is a root user. The caller should
// enrich the error with the specific operation using errors.WithAdditionalf.
func (u *StorableUser) ErrIfRoot() error {
if u.IsRoot {
return errors.New(errors.TypeUnsupported, ErrCodeRootUserOperationUnsupported, "this operation is not supported for the root user")
}
return nil
}
// ErrIfDeleted returns an error if the user is in deleted state.
// This error can be enriched with specific operation by the called using errors.WithAdditionalf
func (u *User) ErrIfDeleted() error {
@@ -177,6 +250,15 @@ func (u *User) ErrIfDeleted() error {
return nil
}
// ErrIfDeleted returns an error if the user is in deleted state.
// This error can be enriched with specific operation by the called using errors.WithAdditionalf
func (u *StorableUser) ErrIfDeleted() error {
if u.Status == UserStatusDeleted {
return errors.New(errors.TypeUnsupported, ErrCodeUserStatusDeleted, "unsupported operation for deleted user")
}
return nil
}
// ErrIfPending returns an error if the user is in pending invite state.
// This error can be enriched with specific operation by the called using errors.WithAdditionalf
func (u *User) ErrIfPending() error {
@@ -186,10 +268,41 @@ func (u *User) ErrIfPending() error {
return nil
}
func (u *User) PatchRoles(targetRoles []string) ([]string, []string) {
currentRolesSet := make(map[string]struct{}, len(u.Roles))
inputRolesSet := make(map[string]struct{}, len(targetRoles))
for _, role := range u.Roles {
currentRolesSet[role] = struct{}{}
}
for _, role := range targetRoles {
inputRolesSet[role] = struct{}{}
}
// additions: roles present in input but not in current
additions := []string{}
for _, role := range targetRoles {
if _, exists := currentRolesSet[role]; !exists {
additions = append(additions, role)
}
}
// deletions: roles present in current but not in input
deletions := []string{}
for _, role := range u.Roles {
if _, exists := inputRolesSet[role]; !exists {
deletions = append(deletions, role)
}
}
return additions, deletions
}
func NewTraitsFromUser(user *User) map[string]any {
return map[string]any{
"name": user.DisplayName,
"role": user.Role,
"roles": user.Roles,
"email": user.Email.String(),
"display_name": user.DisplayName,
"status": user.Status,
@@ -215,33 +328,33 @@ func (request *PostableRegisterOrgAndAdmin) UnmarshalJSON(data []byte) error {
type UserStore interface {
// Creates a user.
CreateUser(ctx context.Context, user *User) error
CreateUser(ctx context.Context, user *StorableUser) error
// Get user by id.
GetUser(context.Context, valuer.UUID) (*User, error)
GetUser(context.Context, valuer.UUID) (*StorableUser, error)
// Get user by orgID and id.
GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*User, error)
GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*StorableUser, error)
// Get user by email and orgID.
GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*User, error)
GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*StorableUser, error)
// Get users by email.
GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*User, error)
GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*StorableUser, error)
// Get users by role and org.
GetActiveUsersByRoleAndOrgID(ctx context.Context, role Role, orgID valuer.UUID) ([]*User, error)
// Get active users by role name and org. join to user_role table.
GetActiveUsersByRoleNameAndOrgID(ctx context.Context, roleName string, orgID valuer.UUID) ([]*StorableUser, error)
// List users by org.
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*User, error)
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*StorableUser, error)
// List users by email and org ids.
ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*User, error)
ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*StorableUser, error)
// Get users for an org id using emails and statuses
GetUsersByEmailsOrgIDAndStatuses(context.Context, valuer.UUID, []string, []string) ([]*User, error)
GetUsersByEmailsOrgIDAndStatuses(context.Context, valuer.UUID, []string, []string) ([]*StorableUser, error)
UpdateUser(ctx context.Context, orgID valuer.UUID, user *User) error
UpdateUser(ctx context.Context, orgID valuer.UUID, user *StorableUser) error
DeleteUser(ctx context.Context, orgID string, id string) error
SoftDeleteUser(ctx context.Context, orgID string, id string) error
@@ -267,10 +380,10 @@ type UserStore interface {
CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error)
// Get root user by org.
GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*User, error)
GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*StorableUser, error)
// Get user by reset password token
GetUserByResetPasswordToken(ctx context.Context, token string) (*User, error)
GetUserByResetPasswordToken(ctx context.Context, token string) (*StorableUser, error)
// Transaction
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error

View File

@@ -172,7 +172,7 @@ def clickhouse(
(
'version="v0.0.1" && '
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
'node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && '
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
"cd /tmp && "
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
"tar -xzf histogram-quantile.tar.gz && "

View File

@@ -2,6 +2,7 @@ import platform
import time
from http import HTTPStatus
from os import path
from typing import Optional
import docker
import docker.errors
@@ -16,8 +17,7 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
def create_signoz(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
@@ -25,9 +25,12 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "signoz",
env_overrides: Optional[dict] = None,
) -> types.SigNoz:
"""
Package-scoped fixture for setting up SigNoz.
Factory function for creating a SigNoz container.
Accepts optional env_overrides to customize the container environment.
"""
def create() -> types.SigNoz:
@@ -81,6 +84,9 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
if with_web:
env["SIGNOZ_WEB_ENABLED"] = True
if env_overrides:
env = env | env_overrides
container = DockerContainer("signoz:integration")
for k, v in env.items():
container.with_env(k, v)
@@ -169,7 +175,7 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
return dev.wrap(
request,
pytestconfig,
"signoz",
cache_key,
empty=lambda: types.SigNoz(
self=types.TestContainerDocker(
id="",
@@ -185,3 +191,27 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
delete=delete,
restore=restore,
)
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped fixture for setting up SigNoz.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
)

View File

@@ -664,7 +664,9 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
# --- Step 3: SSO login should be blocked for deleted user ---
create_user_idp(email, "password", True, "SAML", "Lifecycle")
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
perform_saml_login(
signoz, driver, get_session_context, idp_login, email, "password"
)
# Verify user is NOT reactivated — check via DB since API may filter deleted users
with signoz.sqlstore.conn.connect() as conn:
@@ -683,7 +685,11 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
headers={"Authorization": f"Bearer {admin_token}"},
)
found_user = next(
(user for user in response.json()["data"] if user["email"] == email and user["id"] != user_id),
(
user
for user in response.json()["data"]
if user["email"] == email and user["id"] != user_id
),
None,
)
assert found_user is not None

View File

@@ -4,7 +4,6 @@ from urllib.parse import urlparse
import requests
from selenium import webdriver
from sqlalchemy import sql
from wiremock.resources.mappings import Mapping
from fixtures.auth import (

View File

@@ -6,7 +6,6 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.cloudintegrations import create_cloud_integration_account
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.logger import setup_logger
@@ -168,14 +167,14 @@ def test_duplicate_cloud_account_checkins(
assert account1_id != account2_id, "Two accounts should have different internal IDs"
# First check-in succeeds: account1 claims cloud_account_id
# First check-in succeeds: account1 claims cloud_account_id
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account1_id, same_cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
#
#
# Second check-in should fail: account2 tries to use the same cloud_account_id
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account2_id, same_cloud_account_id

View File

@@ -21,6 +21,9 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
GATEWAY_APIS_EDITOR_EMAIL = "gatewayapiseditor@integration.test"
GATEWAY_APIS_EDITOR_PASSWORD = "password123Z$"
def test_apply_license(
signoz: types.SigNoz,
@@ -32,6 +35,31 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
def test_create_editor_user(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Invite and register an editor user for gateway API tests."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": GATEWAY_APIS_EDITOR_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert invite_response.status_code == HTTPStatus.CREATED
reset_token = invite_response.json()["data"]["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": GATEWAY_APIS_EDITOR_PASSWORD, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# ---------------------------------------------------------------------------
# Ingestion key CRUD
# ---------------------------------------------------------------------------
@@ -44,7 +72,7 @@ def test_create_ingestion_key(
get_token: Callable[[str, str], str],
) -> None:
"""POST /api/v2/gateway/ingestion_keys creates a key via the gateway."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
make_http_mocks(
signoz.gateway,
@@ -77,7 +105,7 @@ def test_create_ingestion_key(
"tags": ["env:test", "team:platform"],
"expires_at": "2030-01-01T00:00:00Z",
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -103,7 +131,7 @@ def test_get_ingestion_keys(
get_token: Callable[[str, str], str],
) -> None:
"""GET /api/v2/gateway/ingestion_keys lists keys via the gateway."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
# Default page=1, per_page=10 → gateway gets ?page=1&per_page=10
make_http_mocks(
@@ -146,7 +174,7 @@ def test_get_ingestion_keys(
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/gateway/ingestion_keys"),
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -168,7 +196,7 @@ def test_get_ingestion_keys_custom_pagination(
get_token: Callable[[str, str], str],
) -> None:
"""GET /api/v2/gateway/ingestion_keys with custom pagination params."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
make_http_mocks(
signoz.gateway,
@@ -200,7 +228,7 @@ def test_get_ingestion_keys_custom_pagination(
signoz.self.host_configs["8080"].get(
"/api/v2/gateway/ingestion_keys?page=2&per_page=5"
),
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -221,7 +249,7 @@ def test_search_ingestion_keys(
get_token: Callable[[str, str], str],
) -> None:
"""GET /api/v2/gateway/ingestion_keys/search searches keys by name."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
# name, page, per_page are sorted alphabetically by Go url.Values.Encode()
make_http_mocks(
@@ -266,7 +294,7 @@ def test_search_ingestion_keys(
signoz.self.host_configs["8080"].get(
"/api/v2/gateway/ingestion_keys/search?name=my-test"
),
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -286,7 +314,7 @@ def test_search_ingestion_keys_empty(
get_token: Callable[[str, str], str],
) -> None:
"""Search returns an empty list when no keys match."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
make_http_mocks(
signoz.gateway,
@@ -318,7 +346,7 @@ def test_search_ingestion_keys_empty(
signoz.self.host_configs["8080"].get(
"/api/v2/gateway/ingestion_keys/search?name=nonexistent"
),
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -338,7 +366,7 @@ def test_update_ingestion_key(
get_token: Callable[[str, str], str],
) -> None:
"""PATCH /api/v2/gateway/ingestion_keys/{keyId} updates a key via the gateway."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}"
@@ -366,7 +394,7 @@ def test_update_ingestion_key(
"tags": ["env:prod"],
"expires_at": "2031-06-15T00:00:00Z",
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -388,7 +416,7 @@ def test_delete_ingestion_key(
get_token: Callable[[str, str], str],
) -> None:
"""DELETE /api/v2/gateway/ingestion_keys/{keyId} deletes a key via the gateway."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}"
@@ -411,7 +439,7 @@ def test_delete_ingestion_key(
signoz.self.host_configs["8080"].get(
f"/api/v2/gateway/ingestion_keys/{TEST_KEY_ID}"
),
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)

View File

@@ -10,7 +10,7 @@ from wiremock.client import (
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.auth import add_license
from fixtures.gatewayutils import (
TEST_KEY_ID,
TEST_LIMIT_ID,
@@ -22,6 +22,9 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
GATEWAY_APIS_EDITOR_EMAIL = "gatewayapiseditor@integration.test"
GATEWAY_APIS_EDITOR_PASSWORD = "password123Z$"
def test_apply_license(
signoz: types.SigNoz,
@@ -45,7 +48,7 @@ def test_create_ingestion_key_limit_only_size(
get_token: Callable[[str, str], str],
) -> None:
"""Creating a limit with only size omits count from the gateway payload."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits"
@@ -79,7 +82,7 @@ def test_create_ingestion_key_limit_only_size(
"config": {"day": {"size": 1000}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -105,7 +108,7 @@ def test_create_ingestion_key_limit_only_count(
get_token: Callable[[str, str], str],
) -> None:
"""Creating a limit with only count omits size from the gateway payload."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits"
@@ -139,7 +142,7 @@ def test_create_ingestion_key_limit_only_count(
"config": {"day": {"count": 500}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -162,7 +165,7 @@ def test_create_ingestion_key_limit_both_size_and_count(
get_token: Callable[[str, str], str],
) -> None:
"""Creating a limit with both size and count includes both in the gateway payload."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/keys/{TEST_KEY_ID}/limits"
@@ -199,7 +202,7 @@ def test_create_ingestion_key_limit_both_size_and_count(
},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -229,7 +232,7 @@ def test_update_ingestion_key_limit_only_size(
get_token: Callable[[str, str], str],
) -> None:
"""Updating a limit with only size omits count from the gateway payload."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -256,7 +259,7 @@ def test_update_ingestion_key_limit_only_size(
"config": {"day": {"size": 2000}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -279,7 +282,7 @@ def test_update_ingestion_key_limit_only_count(
get_token: Callable[[str, str], str],
) -> None:
"""Updating a limit with only count omits size from the gateway payload."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -306,7 +309,7 @@ def test_update_ingestion_key_limit_only_count(
"config": {"day": {"count": 750}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -328,7 +331,7 @@ def test_update_ingestion_key_limit_both_size_and_count(
get_token: Callable[[str, str], str],
) -> None:
"""Updating a limit with both size and count includes both in the gateway payload."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -355,7 +358,7 @@ def test_update_ingestion_key_limit_both_size_and_count(
"config": {"day": {"size": 1000, "count": 500}},
"tags": ["test"],
},
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)
@@ -382,7 +385,7 @@ def test_delete_ingestion_key_limit(
get_token: Callable[[str, str], str],
) -> None:
"""DELETE /api/v2/gateway/ingestion_keys/limits/{limitId} deletes a limit."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(GATEWAY_APIS_EDITOR_EMAIL, GATEWAY_APIS_EDITOR_PASSWORD)
gateway_url = f"/v1/workspaces/me/limits/{TEST_LIMIT_ID}"
@@ -405,7 +408,7 @@ def test_delete_ingestion_key_limit(
signoz.self.host_configs["8080"].get(
f"/api/v2/gateway/ingestion_keys/limits/{TEST_LIMIT_ID}"
),
headers={"Authorization": f"Bearer {admin_token}"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=10,
)

View File

@@ -110,9 +110,7 @@ def test_invite_and_register(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "editor@integration.test", "role": "EDITOR", "name": "editor"},
timeout=2,
headers={
"Authorization": f"Bearer {admin_token}"
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
@@ -139,9 +137,7 @@ def test_invite_and_register(
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {editor_token}"
},
headers={"Authorization": f"Bearer {editor_token}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
@@ -194,7 +190,6 @@ def test_revoke_invite_and_register(
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try to use the reset token — should fail (user deleted)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
@@ -239,7 +234,11 @@ def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], s
# invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "oldinviteflow@integration.test", "role": "VIEWER", "name": "old invite flow"},
json={
"email": "oldinviteflow@integration.test",
"role": "VIEWER",
"name": "old invite flow",
},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
@@ -249,9 +248,7 @@ def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], s
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={
"Authorization": f"Bearer {admin_token}"
},
headers={"Authorization": f"Bearer {admin_token}"},
)
invite_response = response.json()["data"]
@@ -297,15 +294,17 @@ def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], s
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {admin_token}"
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "oldinviteflow@integration.test"),
(
user
for user in user_response
if user["email"] == "oldinviteflow@integration.test"
),
None,
)

View File

@@ -63,7 +63,9 @@ def test_api_key(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
assert found_pat["role"] == "ADMIN"
def test_api_key_role(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
def test_api_key_role(
signoz: types.SigNoz, get_token: Callable[[str, str], str]
) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
response = requests.post(

View File

@@ -6,8 +6,6 @@ import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import SigNoz
from sqlalchemy import sql
def test_reinvite_deleted_user(
signoz: SigNoz,
@@ -31,7 +29,11 @@ def test_reinvite_deleted_user(
# invite the user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": reinvite_user_email, "role": reinvite_user_role, "name": reinvite_user_name},
json={
"email": reinvite_user_email,
"role": reinvite_user_role,
"name": reinvite_user_name,
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -58,21 +60,27 @@ def test_reinvite_deleted_user(
# Re-invite the same email — should succeed
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": reinvite_user_email, "role": "VIEWER", "name": "reinvite user v2"},
json={
"email": reinvite_user_email,
"role": "VIEWER",
"name": "reinvite user v2",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
reinvited_user = response.json()["data"]
assert reinvited_user["role"] == "VIEWER"
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
assert reinvited_user["role"] == "VIEWER"
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
reinvited_user_reset_password_token = reinvited_user["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "newPassword123Z$", "token": reinvited_user_reset_password_token},
json={
"password": "newPassword123Z$",
"token": reinvited_user_reset_password_token,
},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -95,8 +103,16 @@ def test_bulk_invite(
signoz.self.host_configs["8080"].get("/api/v1/invite/bulk"),
json={
"invites": [
{"email": "bulk1@integration.test", "role": "EDITOR", "name": "bulk user 1"},
{"email": "bulk2@integration.test", "role": "VIEWER", "name": "bulk user 2"},
{
"email": "bulk1@integration.test",
"role": "EDITOR",
"name": "bulk user 1",
},
{
"email": "bulk2@integration.test",
"role": "VIEWER",
"name": "bulk user 2",
},
]
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -42,7 +42,11 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 1: invite and delete the first user
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v1"},
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"name": "unique index user v1",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -59,7 +63,11 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 2: re-invite and delete the same email (second deleted row)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v2"},
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"name": "unique index user v2",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -85,9 +93,9 @@ def test_unique_index_allows_multiple_deleted_rows(
)
deleted_rows = result.fetchall()
assert len(deleted_rows) == 2, (
f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}"
)
assert (
len(deleted_rows) == 2
), f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}"
deleted_ids = {row[0] for row in deleted_rows}
assert first_user_id in deleted_ids
assert second_user_id in deleted_ids

View File

@@ -585,13 +585,14 @@ def test_metrics_fill_formula_with_group_by(
context=f"metrics/{fill_mode}/F1/{group}",
)
def test_histogram_p90_returns_404_outside_data_window(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[List[Metrics]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
metric_name = "test_p90_last_seen_bucket"

View File

@@ -373,6 +373,7 @@ def test_histogram_count_no_param(
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert values[-1]["value"] == last_values[le]
@pytest.mark.parametrize(
"space_agg, zeroth_value, first_value, last_value",
[
@@ -423,6 +424,7 @@ def test_histogram_percentile_for_all_services(
assert result_values[1]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"space_agg, first_value, last_value",
[
@@ -472,6 +474,7 @@ def test_histogram_percentile_for_cumulative_service(
assert result_values[0]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"space_agg, zeroth_value, first_value, last_value",
[
@@ -521,4 +524,4 @@ def test_histogram_percentile_for_delta_service(
assert len(result_values) == 60
assert result_values[0]["value"] == zeroth_value
assert result_values[1]["value"] == first_value
assert result_values[-1]["value"] == last_value
assert result_values[-1]["value"] == last_value

View File

@@ -0,0 +1,31 @@
import time
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_root_user_created(signoz: types.SigNoz) -> None:
"""
The root user service reconciles asynchronously after startup.
Wait until the root user is available by polling /api/v1/version.
"""
for attempt in range(15):
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/version"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
if response.json().get("setupCompleted") is True:
return
logger.info(
"Attempt %s: setupCompleted is not yet true, retrying ...",
attempt + 1,
)
time.sleep(2)
raise AssertionError("root user was not created within the expected time")

View File

@@ -0,0 +1,62 @@
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_global_config_returns_impersonation_enabled(signoz: types.SigNoz) -> None:
"""
GET /api/v1/global/config without any auth header should return 200
and report impersonation as enabled.
"""
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/global/config"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["identN"]["impersonation"]["enabled"] is True
assert data["identN"]["tokenizer"]["enabled"] is False
assert data["identN"]["apikey"]["enabled"] is False
def test_unauthenticated_request_succeeds(signoz: types.SigNoz) -> None:
"""
With impersonation enabled, requests without any auth header
should succeed as the root user (admin).
"""
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
def test_impersonated_user_is_admin(signoz: types.SigNoz) -> None:
"""
The impersonated identity should have admin privileges.
Listing users is an admin-only endpoint.
"""
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
)
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
assert len(users) >= 1
root_user = next(
(u for u in users if u.get("isRoot") is True),
None,
)
assert root_user is not None
assert root_user["role"] == "ADMIN"

View File

@@ -0,0 +1,41 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
ROOT_USER_EMAIL = "rootuser@integration.test"
ROOT_USER_PASSWORD = "password123Z$"
@pytest.fixture(name="signoz", scope="package")
def signoz_rootuser(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped fixture for SigNoz with root user and impersonation enabled.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-rootuser",
env_overrides={
"SIGNOZ_IDENTN_IMPERSONATION_ENABLED": True,
"SIGNOZ_IDENTN_TOKENIZER_ENABLED": False,
"SIGNOZ_IDENTN_APIKEY_ENABLED": False,
"SIGNOZ_USER_ROOT_ENABLED": True,
"SIGNOZ_USER_ROOT_EMAIL": ROOT_USER_EMAIL,
"SIGNOZ_USER_ROOT_PASSWORD": ROOT_USER_PASSWORD,
},
)