mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-19 11:12:18 +00:00
Compare commits
21 Commits
fix/filter
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aad23ab1c | ||
|
|
0455037025 | ||
|
|
3102e76e80 | ||
|
|
774e9e49dc | ||
|
|
29c3d10769 | ||
|
|
2a75e141dc | ||
|
|
ea5efbb68e | ||
|
|
15451985e5 | ||
|
|
ef5c36b2c3 | ||
|
|
ad83b0e09b | ||
|
|
4f3b75a914 | ||
|
|
809c66e27e | ||
|
|
57442ef4a4 | ||
|
|
267d2b1d42 | ||
|
|
2add13d228 | ||
|
|
c35990ead9 | ||
|
|
f23a364fbe | ||
|
|
29ec71b98f | ||
|
|
ca9cbd92e4 | ||
|
|
0faef8705d | ||
|
|
2ca9085b52 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -49,6 +49,7 @@ jobs:
|
||||
- ttl
|
||||
- alerts
|
||||
- ingestionkeys
|
||||
- rootuser
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ describe('RightContainer - Alerts Section', () => {
|
||||
setLineStyle: jest.fn(),
|
||||
showPoints: false,
|
||||
setShowPoints: jest.fn(),
|
||||
spanGaps: false,
|
||||
setSpanGaps: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">></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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ interface FillModeSelectorProps {
|
||||
onChange: (value: FillMode) => void;
|
||||
}
|
||||
|
||||
export function FillModeSelector({
|
||||
export default function FillModeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: FillModeSelectorProps): JSX.Element {
|
||||
|
||||
@@ -9,7 +9,7 @@ interface LineInterpolationSelectorProps {
|
||||
onChange: (value: LineInterpolation) => void;
|
||||
}
|
||||
|
||||
export function LineInterpolationSelector({
|
||||
export default function LineInterpolationSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: LineInterpolationSelectorProps): JSX.Element {
|
||||
|
||||
@@ -9,7 +9,7 @@ interface LineStyleSelectorProps {
|
||||
onChange: (value: LineStyle) => void;
|
||||
}
|
||||
|
||||
export function LineStyleSelector({
|
||||
export default function LineStyleSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: LineStyleSelectorProps): JSX.Element {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
16
frontend/src/lib/uPlotV2/components/UPlotChart/utils.ts
Normal file
16
frontend/src/lib/uPlotV2/components/UPlotChart/utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface IBaseWidget {
|
||||
showPoints?: boolean;
|
||||
lineStyle?: LineStyle;
|
||||
fillMode?: FillMode;
|
||||
spanGaps?: boolean | number;
|
||||
}
|
||||
export interface Widgets extends IBaseWidget {
|
||||
query: Query;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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().
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
96
pkg/identn/impersonationidentn/provider.go
Normal file
96
pkg/identn/impersonationidentn/provider.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 == "" {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
62
pkg/modules/user/impluser/userrolestore.go
Normal file
62
pkg/modules/user/impluser/userrolestore.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{},
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
197
pkg/sqlmigration/071_add_user_role.go
Normal file
197
pkg/sqlmigration/071_add_user_role.go
Normal 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
|
||||
}
|
||||
156
pkg/sqlmigration/072_add_user_role_authz.go
Normal file
156
pkg/sqlmigration/072_add_user_role_authz.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
104
pkg/types/authtypes/user_role.go
Normal file
104
pkg/types/authtypes/user_role.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
13
pkg/types/globaltypes/config.go
Normal file
13
pkg/types/globaltypes/config.go
Normal 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,
|
||||
}
|
||||
}
|
||||
13
pkg/types/globaltypes/endpoint.go
Normal file
13
pkg/types/globaltypes/endpoint.go
Normal 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,
|
||||
}
|
||||
}
|
||||
27
pkg/types/globaltypes/identn.go
Normal file
27
pkg/types/globaltypes/identn.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && "
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
31
tests/integration/src/rootuser/01_rootuser.py
Normal file
31
tests/integration/src/rootuser/01_rootuser.py
Normal 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")
|
||||
62
tests/integration/src/rootuser/02_impersonation.py
Normal file
62
tests/integration/src/rootuser/02_impersonation.py
Normal 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"
|
||||
0
tests/integration/src/rootuser/__init__.py
Normal file
0
tests/integration/src/rootuser/__init__.py
Normal file
41
tests/integration/src/rootuser/conftest.py
Normal file
41
tests/integration/src/rootuser/conftest.py
Normal 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,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user