Compare commits

..

32 Commits

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

* fix: fmtlint and middleware

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

* fix: prevent nil pointer error

* feat: dry org code by implementing getbyidorname

* feat: add integration tests for root user and impersonation

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

* feat: added changes for spangaps thresholds

* chore: minor changes

* fix: fixed failing tests

* fix: minor style fixes

* chore: updated the categorisation

* feat: added chart appearance settings in panel

* feat: added fill mode in timeseries

* chore: broke down rightcontainer component into sub-components

* chore: minor cleanup

* chore: fixed disconnect points logic

* chore: added spanGaps selection component

* chore: updated rightcontainer

* fix: added fix for the UI

* chore: fixed ui issues

* chore: fixed styles

* chore: default spangaps to true

* chore: moved preparedata to utils

* chore: hidden chart appearance

* chore: pr review comments

* chore: pr review comments

* chore: updated variable names

* chore: fixed minor changes

* chore: updated test

* chore: updated test
2026-03-19 09:40:02 +00:00
Ashwin Bhatkal
2ca9085b52 chore: add eslint rule for zustand getState (#10648) 2026-03-19 09:13:09 +00:00
Piyush Singariya
b7d0c8b5a2 Revert "Revert "fix: "In Progress" stuck agent config (#10476)" (#10633)" (#10644)
This reverts commit c8fcc48022.
2026-03-19 04:48:17 +00:00
Vinicius Lourenço
ce5499d5a7 feat(authz): migrate authorization to authz instead of user.role (#10486)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(authz): migrate authorization to authz instead of user.role

* fixup! feat(authz): migrate authorization to authz instead of user.role

address comments

* fixup! fixup! feat(authz): migrate authorization to authz instead of user.role

Allow anonymous to go to unauthorized, otherwise it will loop in errors

* fixup! fixup! fixup! feat(authz): migrate authorization to authz instead of user.role

Improve error message when anonymous

* fixup! fixup! fixup! fixup! feat(authz): migrate authorization to authz instead of user.role

Format breaking with new css
2026-03-18 18:24:11 +00:00
Pandey
4554a09a42 fix: handle foreign key constraint on rule and planned maintenance deletion (#10632)
* fix: handle foreign key constraint on rule and planned maintenance deletion

* fix: handle foreign key constraint on rule and planned maintenance deletion

* fix: handle foreign key constraint on rule and planned maintenance deletion
2026-03-18 16:38:37 +00:00
swapnil-signoz
794a7f4ca6 fix: adding migration to fix wrong index on cloud integration table (#10607)
* fix: adding migration for fixing wrong cloud integration unique index

* refactor: removing std errors pkg

* refactor: normalising account_id if empty

* feat: adding integration test
2026-03-18 16:01:55 +00:00
aniketio-ctrl
fd3b1c5374 fix(checkout): pass downstream error meesage to UI (#10636)
* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI
2026-03-18 15:28:01 +00:00
Vinicius Lourenço
e52c5683dd feat(signozhq-ui): add @signozhq/ui lib (#10616) 2026-03-18 13:44:25 +00:00
Abhi kumar
90e3cb6775 feat: replaced external apis barchart with the new bar chart (#10460)
* feat: replaced external apis barchart with the new bar chart

* fix: tests

* chore: fixed tsc
2026-03-18 13:36:23 +00:00
primus-bot[bot]
155f287462 chore(release): bump to v0.116.1 (#10635)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-03-18 12:28:33 +00:00
Piyush Singariya
c8fcc48022 Revert "fix: "In Progress" stuck agent config (#10476)" (#10633)
This reverts commit fd19ff8e5e.
2026-03-18 11:30:39 +00:00
Vikrant Gupta
44b6885639 fix(identn): identn provider claims (#10631)
* fix(identn): identn provider claims

* fix(identn): add integration tests

* fix(identn): use identn provider from claims
2026-03-18 11:23:50 +00:00
Piyush Singariya
0e5a128325 refactor: consolidate body column for JSON logs (#10325)
* feat: has JSON QB

* fix: tests expected queries and values

* fix: ignored .vscode in gitignore

* fix: tests GroupBy

* revert: gitignore change

* fix: build json plans in metadata

* fix: empty filteredArrays condition

* fix: tests

* fix: tests

* fix: json qb test fix

* fix: review based on tushar

* fix: changes based on review from Srikanth

* fix: remove unnecessary bool checking

* fix: removed comment

* fix: merge json body columns together

* chore: var renamed

* fix: merge conflict

* test: fix

* fix: tests

* fix: go test flakiness

* chore: merge json fields

* fix: handle datatype collision

* revert: few unrelated changes

* revert: more unrelated change

* test: blocked on pr #10153

* feat: mapping body_v2.message:string map to body

* fix: go.mod required changes

* fix: remove unused function

* fix: test fixed

* fix: go mod changes

* fix: tests

* fix: go lint

* revert: remvoing unused function

* revert: change ReadMultiple is needed

* fix: body.message not being mapped correctly

* fix: append warnings from fieldkeys

* fix: change warning to a const to fix tests

* chore: addressing comments from Nitya

* chore: remove unnecessary change

* fix: shift warning attachment to getKeySelectors

* fix: lint error

* feat: update message as typehint in JSON Column (#10545)

* fix: cursor comments

* chore: minor changes based on review

* fix: message field key search in JSON Logs (#10577)

* feat: work in progress

* fix: test run success

* fix: in progress

* fix: excluding message from metadata fetch

* test: cleared

* fix: key name in metadata

* fix: uncomment tests

* chore: change to method for staticfields

* fix: remove confusing comments; remove usage of logical keyword

* chore: shift method above business logic

* chore: changes based on review

* fix: comments in metadata_store.go

* fix: fallback expr switch case

* revert: remove unused JSON Field datatype

* fix: remove the exception checking

* chore: keep message contained to field mapper

* chore: text search tests

* fix: package test fixed

* fix: redundant code block removal

* fix: retain staticfield implementation and spell fix

* fix: nil param lint

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-03-18 10:48:17 +00:00
166 changed files with 5059 additions and 1201 deletions

View File

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

View File

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

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.116.0
image: signoz/signoz:v0.116.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.116.0
image: signoz/signoz:v0.116.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.116.0}
image: signoz/signoz:${VERSION:-v0.116.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.116.0}
image: signoz/signoz:${VERSION:-v0.116.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

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

View File

@@ -198,7 +198,10 @@ func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UU
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate checkout session")
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
@@ -217,7 +220,7 @@ func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate portal session")
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil

View File

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

View File

@@ -101,7 +101,7 @@ func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format s
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if errors.As(err, &pgErr) && (pgErr.Code == "23505" || pgErr.Code == "23503") {
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
}

View File

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

View File

@@ -24,7 +24,8 @@ const config: Config.InitialOptions = {
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
'^react-syntax-highlighter/dist/esm/(.*)$':
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
'^@signozhq/([^/]+)$': '<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
'^@signozhq/(?!ui$)([^/]+)$':
'<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
},
extensionsToTreatAsEsm: ['.ts'],
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],

View File

@@ -67,6 +67,7 @@
"@signozhq/table": "0.3.7",
"@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

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

View File

@@ -81,7 +81,8 @@ export const interceptorRejected = async (
response.config.url !== '/sessions/email_password' &&
!(
response.config.url === '/sessions' && response.config.method === 'delete'
)
) &&
response.config.url !== '/authz/check'
) {
try {
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);

View File

@@ -0,0 +1,152 @@
import axios, { AxiosHeaders, AxiosResponse } from 'axios';
import { interceptorRejected } from './index';
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: jest.fn(() => 'mock-token'),
}));
jest.mock('api/v2/sessions/rotate/post', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: { accessToken: 'new-token', refreshToken: 'new-refresh' },
}),
),
}));
jest.mock('AppRoutes/utils', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('axios', () => {
const actualAxios = jest.requireActual('axios');
const mockAxios = jest.fn().mockResolvedValue({ data: 'success' });
return {
...actualAxios,
default: Object.assign(mockAxios, {
...actualAxios.default,
isAxiosError: jest.fn().mockReturnValue(true),
create: actualAxios.create,
}),
__esModule: true,
};
});
describe('interceptorRejected', () => {
beforeEach(() => {
jest.clearAllMocks();
((axios as unknown) as jest.Mock).mockResolvedValue({ data: 'success' });
((axios.isAxiosError as unknown) as jest.Mock).mockReturnValue(true);
});
it('should preserve array payload structure when retrying a 401 request', async () => {
const arrayPayload = [
{ relation: 'assignee', object: { resource: { name: 'role' } } },
{ relation: 'assignee', object: { resource: { name: 'editor' } } },
];
const error = ({
response: {
status: 401,
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(arrayPayload),
},
},
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(arrayPayload),
},
} as unknown) as AxiosResponse;
try {
await interceptorRejected(error);
} catch {
// Expected to reject after retry
}
const mockAxiosFn = (axios as unknown) as jest.Mock;
expect(mockAxiosFn.mock.calls.length).toBe(1);
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
expect(Array.isArray(JSON.parse(retryCallConfig.data))).toBe(true);
expect(JSON.parse(retryCallConfig.data)).toEqual(arrayPayload);
});
it('should preserve object payload structure when retrying a 401 request', async () => {
const objectPayload = { key: 'value', nested: { data: 123 } };
const error = ({
response: {
status: 401,
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(objectPayload),
},
},
config: {
url: '/some-endpoint',
method: 'POST',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: JSON.stringify(objectPayload),
},
} as unknown) as AxiosResponse;
try {
await interceptorRejected(error);
} catch {
// Expected to reject after retry
}
const mockAxiosFn = (axios as unknown) as jest.Mock;
expect(mockAxiosFn.mock.calls.length).toBe(1);
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
expect(JSON.parse(retryCallConfig.data)).toEqual(objectPayload);
});
it('should handle undefined data gracefully when retrying', async () => {
const error = ({
response: {
status: 401,
config: {
url: '/some-endpoint',
method: 'GET',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: undefined,
},
},
config: {
url: '/some-endpoint',
method: 'GET',
baseURL: 'http://localhost/',
headers: new AxiosHeaders(),
data: undefined,
},
} as unknown) as AxiosResponse;
try {
await interceptorRejected(error);
} catch {
// Expected to reject after retry
}
const mockAxiosFn = (axios as unknown) as jest.Mock;
expect(mockAxiosFn.mock.calls.length).toBe(1);
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
expect(retryCallConfig.data).toBeUndefined();
});
});

View File

@@ -1,8 +1,14 @@
function UnAuthorized(): JSX.Element {
function UnAuthorized({
width = 137,
height = 137,
}: {
height?: number;
width?: number;
}): JSX.Element {
return (
<svg
width="137"
height="137"
width={width}
height={height}
viewBox="0 0 137 137"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -30,3 +30,4 @@ import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/toggle-group';
import '@signozhq/tooltip';
import '@signozhq/ui';

View File

@@ -1,13 +1,13 @@
import { createShortcutActions } from '../../constants/shortcutActions';
import { useCmdK } from '../../providers/cmdKProvider';
import { ROLES } from '../../types/roles';
import { ShiftOverlay } from './ShiftOverlay';
import { useShiftHoldOverlay } from './useShiftHoldOverlay';
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function ShiftHoldOverlayController({
userRole,
}: {
userRole: UserRole;
userRole: ROLES;
}): JSX.Element | null {
const { open: isCmdKOpen } = useCmdK();
const noop = (): void => undefined;

View File

@@ -1,18 +1,18 @@
import { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { ROLES } from 'types/roles';
import { formatShortcut } from './formatShortcut';
import './shiftOverlay.scss';
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
roles?: UserRole[];
roles?: ROLES[];
perform: () => void;
};
@@ -33,7 +33,7 @@ function Shortcut({ label, keyHint }: ShortcutProps): JSX.Element {
interface ShiftOverlayProps {
visible: boolean;
actions: CmdAction[];
userRole: UserRole;
userRole: ROLES;
}
export function ShiftOverlay({

View File

@@ -11,6 +11,7 @@ import {
import logEvent from 'api/common/logEvent';
import { useThemeMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
import { createShortcutActions } from '../../constants/shortcutActions';
import { useCmdK } from '../../providers/cmdKProvider';
@@ -28,7 +29,6 @@ type CmdAction = {
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {

View File

@@ -18,8 +18,7 @@ import {
TowerControl,
Workflow,
} from 'lucide-react';
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
import { ROLES } from 'types/roles';
export type CmdAction = {
id: string;
@@ -28,7 +27,7 @@ export type CmdAction = {
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: UserRole[];
roles?: ROLES[];
perform: () => void;
};

View File

@@ -3,16 +3,14 @@ import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import { useGetGraphCustomSeries } from 'components/CeleryTask/useGetGraphCustomSeries';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getCustomFiltersForBarChart,
getFormattedEndPointStatusCodeChartData,
getStatusCodeBarChartWidgetData,
statusCodeWidgetInfo,
} from 'container/ApiMonitoring/utils';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
@@ -20,15 +18,16 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useNotifications } from 'hooks/useNotifications';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { Options } from 'uplot';
import ErrorState from './ErrorState';
import { prepareStatusCodeBarChartsConfig } from './utils';
function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
@@ -67,13 +66,6 @@ function StatusCodeBarCharts({
} = endPointStatusCodeLatencyBarChartsDataQuery;
const { startTime: minTime, endTime: maxTime } = timeRange;
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
@@ -119,6 +111,7 @@ function StatusCodeBarCharts({
const navigateToExplorer = useNavigateToExplorer();
const { currentQuery } = useQueryBuilder();
const { timezone } = useTimezone();
const navigateToExplorerPages = useNavigateToExplorerPages();
const { notifications } = useNotifications();
@@ -134,12 +127,6 @@ function StatusCodeBarCharts({
[],
);
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
drawStyle: 'bars',
colorMapping,
});
const widget = useMemo<Widgets>(
() =>
getStatusCodeBarChartWidgetData(domainName, {
@@ -193,49 +180,36 @@ function StatusCodeBarCharts({
],
);
const options = useMemo(
() =>
getUPlotChartOptions({
apiResponse:
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload,
isDarkMode,
dimensions,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: minTime,
maxTimeScale: maxTime,
panelType: PANEL_TYPES.BAR,
onClickHandler: graphClickHandler,
customSeries: getCustomSeries,
onDragSelect,
colorMapping,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
minTime,
maxTime,
currentWidgetInfoIndex,
dimensions,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
const config = useMemo(() => {
const apiResponse =
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload;
return prepareStatusCodeBarChartsConfig({
timezone,
isDarkMode,
graphClickHandler,
getCustomSeries,
query: currentQuery,
onDragSelect,
onClick: graphClickHandler,
apiResponse,
minTimeScale: minTime,
maxTimeScale: maxTime,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
colorMapping,
currentQuery,
],
);
});
}, [
currentQuery,
isDarkMode,
minTime,
maxTime,
graphClickHandler,
onDragSelect,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
timezone,
currentWidgetInfoIndex,
colorMapping,
]);
const renderCardContent = useCallback(
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
@@ -253,11 +227,20 @@ function StatusCodeBarCharts({
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<Uplot options={options as Options} data={chartData} />
<BarChart
config={config}
data={chartData}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
legendConfig={{
position: LegendPosition.BOTTOM,
}}
/>
</div>
);
},
[options, chartData],
[config, chartData, dimensions, timezone],
);
return (

View File

@@ -0,0 +1,83 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { v4 } from 'uuid';
export const prepareStatusCodeBarChartsConfig = ({
timezone,
isDarkMode,
query,
onDragSelect,
onClick,
apiResponse,
minTimeScale,
maxTimeScale,
yAxisUnit,
colorMapping,
}: {
timezone: Timezone;
isDarkMode: boolean;
query: Query;
onDragSelect: (startTime: number, endTime: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
apiResponse: MetricRangePayloadProps;
yAxisUnit?: string;
colorMapping?: Record<string, string>;
}): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const minStepInterval = Math.min(...Object.values(stepIntervals));
const config = buildBaseConfig({
id: v4(),
yAxisUnit: yAxisUnit,
apiResponse,
isDarkMode,
onDragSelect,
timezone,
onClick,
minTimeScale,
maxTimeScale,
stepInterval: minStepInterval,
panelType: PANEL_TYPES.BAR,
});
const seriesList: QueryData[] = apiResponse?.data?.result || [];
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
series.legend || '',
);
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
config.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label: label,
colorMapping: colorMapping ?? {},
isDarkMode,
stepInterval: currentStepInterval,
});
});
return config;
};

View File

@@ -21,10 +21,15 @@ interface MockQueryResult {
}
// Mocks
jest.mock('components/Uplot', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => <div data-testid="uplot-mock" />),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}),
);
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
@@ -70,6 +75,24 @@ jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): {
timezone: {
name: string;
value: string;
offset: string;
searchIndex: string;
};
} => ({
timezone: {
name: 'UTC',
value: 'UTC',
offset: '+00:00',
searchIndex: 'UTC',
},
}),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: jest.fn().mockReturnValue({}),
}));
@@ -319,7 +342,7 @@ describe('StatusCodeBarCharts', () => {
mockData.payload,
'sum',
);
expect(screen.getByTestId('uplot-mock')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart-mock')).toBeInTheDocument();
expect(screen.getByText('Number of calls')).toBeInTheDocument();
expect(screen.getByText('Latency')).toBeInTheDocument();
});

View File

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

View File

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

View File

@@ -337,31 +337,6 @@
.login-submit-btn {
width: 100%;
height: 32px;
padding: 10px 16px;
background: var(--primary);
border: none;
border-radius: 2px;
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--bg-neutral-dark-50);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover:not(:disabled) {
background: var(--primary);
opacity: 0.9;
}
&:disabled {
background: var(--primary);
opacity: 0.6;
cursor: not-allowed;
}
}
.lightMode {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/button';
import { Button } from '@signozhq/ui';
import { Form, Input, Select, Typography } from 'antd';
import getVersion from 'api/v1/version/get';
import get from 'api/v2/sessions/context/get';
@@ -392,9 +392,9 @@ function Login(): JSX.Element {
disabled={!isNextButtonEnabled}
variant="solid"
onClick={onNextHandler}
data-testid="initiate_login"
testId="initiate_login"
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
suffix={<ArrowRight />}
>
Next
</Button>
@@ -406,10 +406,10 @@ function Login(): JSX.Element {
variant="solid"
type="submit"
color="primary"
data-testid="callback_authn_submit"
testId="callback_authn_submit"
data-attr="signup"
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
suffix={<ArrowRight />}
>
Sign in with SSO
</Button>
@@ -420,11 +420,11 @@ function Login(): JSX.Element {
disabled={!isSubmitButtonEnabled}
variant="solid"
color="primary"
data-testid="password_authn_submit"
testId="password_authn_submit"
type="submit"
data-attr="signup"
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
suffix={<ArrowRight />}
>
Sign in with Password
</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
export const AUTHZ_CACHE_TIME = 20_000;

View File

@@ -0,0 +1,18 @@
import { buildPermission } from './utils';
export const IsAdminPermission = buildPermission(
'assignee',
'role:signoz-admin',
);
export const IsEditorPermission = buildPermission(
'assignee',
'role:signoz-editor',
);
export const IsViewerPermission = buildPermission(
'assignee',
'role:signoz-viewer',
);
export const IsAnonymousPermission = buildPermission(
'assignee',
'role:signoz-anonymous',
);

View File

@@ -14,7 +14,7 @@ type ResourceTypeMap = {
type RelationName = keyof RelationsByType;
type ResourcesForRelation<R extends RelationName> = Extract<
export type ResourcesForRelation<R extends RelationName> = Extract<
Resource,
{ type: RelationsByType[R][number] }
>['name'];
@@ -50,8 +50,26 @@ export type AuthZCheckResponse = Record<
}
>;
export type UseAuthZOptions = {
/**
* If false, the query/permissions will not be fetched.
* Useful when you want to disable the query/permissions for a specific use case, like logout.
*
* @default true
*/
enabled?: boolean;
};
export type UseAuthZResult = {
/**
* If query is cached, and refetch happens in background, this is false.
*/
isLoading: boolean;
/**
* If query is fetching, even if happens in background, this is true.
*/
isFetching: boolean;
error: Error | null;
permissions: AuthZCheckResponse | null;
refetchPermissions: () => void;
};

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { authzCheck } from 'api/generated/services/authz';
import type {
@@ -6,7 +6,13 @@ import type {
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AuthZCheckResponse, BrandedPermission, UseAuthZResult } from './types';
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
import {
AuthZCheckResponse,
BrandedPermission,
UseAuthZOptions,
UseAuthZResult,
} from './types';
import {
gettableTransactionToPermission,
permissionToTransactionDto,
@@ -14,8 +20,6 @@ import {
let ctx: Promise<AuthZCheckResponse> | null;
let pendingPermissions: BrandedPermission[] = [];
const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
const AUTHZ_CACHE_TIME = 20_000;
function dispatchPermission(
permission: BrandedPermission,
@@ -70,7 +74,12 @@ async function fetchManyPermissions(
}, {} as AuthZCheckResponse);
}
export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
export function useAuthZ(
permissions: BrandedPermission[],
options?: UseAuthZOptions,
): UseAuthZResult {
const { enabled } = options ?? { enabled: true };
const queryResults = useQueries(
permissions.map((permission) => {
return {
@@ -80,6 +89,7 @@ export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
queryFn: async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
@@ -96,6 +106,10 @@ export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
const isLoading = useMemo(() => queryResults.some((q) => q.isLoading), [
queryResults,
]);
const isFetching = useMemo(() => queryResults.some((q) => q.isFetching), [
queryResults,
]);
const error = useMemo(
() =>
!isLoading
@@ -121,9 +135,17 @@ export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
}, {} as AuthZCheckResponse);
}, [isLoading, error, queryResults]);
const refetchPermissions = useCallback(() => {
for (const query of queryResults) {
query.refetch();
}
}, [queryResults]);
return {
isLoading,
isFetching,
error,
permissions: data ?? null,
refetchPermissions,
};
}

View File

@@ -3,9 +3,9 @@ import permissionsType from './permissions.type';
import {
AuthZObject,
AuthZRelation,
AuthZResource,
BrandedPermission,
ResourceName,
ResourcesForRelation,
ResourceType,
} from './types';
@@ -19,11 +19,10 @@ export function buildPermission<R extends AuthZRelation>(
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
}
export function buildObjectString(
resource: AuthZResource,
objectId: string,
): `${AuthZResource}${typeof ObjectSeparator}${string}` {
return `${resource}${ObjectSeparator}${objectId}` as const;
export function buildObjectString<
R extends 'delete' | 'read' | 'update' | 'assignee'
>(resource: ResourcesForRelation<R>, objectId: string): AuthZObject<R> {
return `${resource}${ObjectSeparator}${objectId}` as AuthZObject<R>;
}
export function parsePermission(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
.unauthorized-page {
&__description {
text-align: center;
}
}

View File

@@ -1,20 +1,51 @@
import { useCallback } from 'react';
import { Space, Typography } from 'antd';
import UnAuthorized from 'assets/UnAuthorized';
import { Button, Container } from 'components/NotFound/styles';
import ROUTES from 'constants/routes';
import { Container } from 'components/NotFound/styles';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useQueryState } from 'nuqs';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useAppContext } from '../../providers/App/App';
import { USER_ROLES } from '../../types/roles';
import './index.styles.scss';
function UnAuthorizePage(): JSX.Element {
return (
<Container>
<Space align="center" direction="vertical">
<UnAuthorized />
<Typography.Title level={3}>
Oops.. you don&apos;t have permission to view this page
</Typography.Title>
const [debugCurrentRole] = useQueryState('currentRole');
const { user } = useAppContext();
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
<Button to={ROUTES.HOME} tabIndex={0} className="periscope-btn primary">
Return To Home
</Button>
const userIsAnonymous =
debugCurrentRole === USER_ROLES.ANONYMOUS ||
user.role === USER_ROLES.ANONYMOUS;
const mistakeMessage = userIsAnonymous
? 'If you believe this is a mistake, please contact your administrator or'
: 'Please contact your administrator.';
const handleContactSupportClick = useCallback((): void => {
handleContactSupport(isCloudUserVal);
}, [isCloudUserVal]);
return (
<Container className="unauthorized-page">
<Space align="center" direction="vertical">
<UnAuthorized width={64} height={64} />
<Typography.Title level={3}>Access Restricted</Typography.Title>
<p className="unauthorized-page__description">
It looks like you don&lsquo;t have permission to view this page. <br />
{mistakeMessage}
{userIsAnonymous ? (
<Typography.Link
className="contact-support-link"
onClick={handleContactSupportClick}
>
{' '}
reach out to us.
</Typography.Link>
) : null}
</p>
</Space>
</Container>
);

View File

@@ -19,6 +19,12 @@ import getUserVersion from 'api/v1/version/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import dayjs from 'dayjs';
import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3';
import {
IsAdminPermission,
IsEditorPermission,
IsViewerPermission,
} from 'hooks/useAuthZ/legacy';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { useGetFeatureFlag } from 'hooks/useGetFeatureFlag';
import { useGlobalEventListener } from 'hooks/useGlobalEventListener';
import { ChangelogSchema } from 'types/api/changelog/getChangelogByVersion';
@@ -34,7 +40,7 @@ import {
UserPreference,
} from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { USER_ROLES } from 'types/roles';
import { ROLES, USER_ROLES } from 'types/roles';
import { IAppContext, IUser } from './types';
import { getUserDefaults } from './utils';
@@ -43,7 +49,7 @@ export const AppContext = createContext<IAppContext | undefined>(undefined);
export function AppProvider({ children }: PropsWithChildren): JSX.Element {
// on load of the provider set the user defaults with access token , refresh token from local storage
const [user, setUser] = useState<IUser>(() => getUserDefaults());
const [defaultUser, setDefaultUser] = useState<IUser>(() => getUserDefaults());
const [activeLicense, setActiveLicense] = useState<LicenseResModel | null>(
null,
);
@@ -70,18 +76,51 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
// if logged out and trying to hit any route none of these calls will trigger
const {
data: userData,
isFetching: isFetchingUser,
error: userFetchError,
isFetching: isFetchingUserData,
error: userFetchDataError,
} = useQuery({
queryFn: get,
queryKey: ['/api/v1/user/me'],
enabled: isLoggedIn,
});
const {
permissions: permissionsResult,
isFetching: isFetchingPermissions,
error: errorOnPermissions,
refetchPermissions,
} = useAuthZ([IsAdminPermission, IsEditorPermission, IsViewerPermission], {
enabled: isLoggedIn,
});
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
const userFetchError = userFetchDataError || errorOnPermissions;
const userRole = useMemo(() => {
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
return USER_ROLES.ADMIN;
}
if (permissionsResult?.[IsEditorPermission]?.isGranted) {
return USER_ROLES.EDITOR;
}
if (permissionsResult?.[IsViewerPermission]?.isGranted) {
return USER_ROLES.VIEWER;
}
// if none of the permissions, so anonymous
return USER_ROLES.ANONYMOUS;
}, [permissionsResult]);
const user: IUser = useMemo(() => {
return {
...defaultUser,
role: userRole as ROLES,
};
}, [defaultUser, userRole]);
useEffect(() => {
if (!isFetchingUser && userData && userData.data) {
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, userData.data.email);
setUser((prev) => ({
setDefaultUser((prev) => ({
...prev,
...userData.data,
}));
@@ -203,7 +242,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
}, [userPreferencesData, isFetchingUserPreferences, isLoggedIn]);
function updateUser(user: IUser): void {
setUser((prev) => ({
setDefaultUser((prev) => ({
...prev,
...user,
}));
@@ -244,7 +283,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
...org.slice(orgIndex + 1, org.length),
];
setOrg(updatedOrg);
setUser((prev) => {
setDefaultUser((prev) => {
if (prev.orgId === orgId) {
return {
...prev,
@@ -272,7 +311,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
// global event listener for AFTER_LOGIN event to start the user fetch post all actions are complete
useGlobalEventListener('AFTER_LOGIN', (event) => {
if (event.detail) {
setUser((prev) => ({
setDefaultUser((prev) => ({
...prev,
accessJwt: event.detail.accessJWT,
refreshJwt: event.detail.refreshJWT,
@@ -280,12 +319,14 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
}));
setIsLoggedIn(true);
}
refetchPermissions();
});
// global event listener for LOGOUT event to clean the app context state
useGlobalEventListener('LOGOUT', () => {
setIsLoggedIn(false);
setUser(getUserDefaults());
setDefaultUser(getUserDefaults());
setActiveLicense(null);
setTrialInfo(null);
setFeatureFlags(null);

View File

@@ -0,0 +1,273 @@
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import setLocalStorageApi from 'api/browser/localstorage/set';
import {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { SINGLE_FLIGHT_WAIT_TIME_MS } from 'hooks/useAuthZ/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { USER_ROLES } from 'types/roles';
import { AppProvider, useAppContext } from '../App';
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
jest.mock('constants/env', () => ({
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
}));
/**
* Since we are mocking the check permissions, this is needed
*/
const waitForSinglePreflightToFinish = async (): Promise<void> =>
await new Promise((r) => setTimeout(r, SINGLE_FLIGHT_WAIT_TIME_MS));
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
});
function createWrapper(): ({
children,
}: {
children: ReactElement;
}) => ReactElement {
return function Wrapper({
children,
}: {
children: ReactElement;
}): ReactElement {
return (
<QueryClientProvider client={queryClient}>
<AppProvider>{children}</AppProvider>
</QueryClientProvider>
);
};
}
describe('AppProvider user.role from permissions', () => {
beforeEach(() => {
queryClient.clear();
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
});
it('sets user.role to ADMIN and hasEditPermission to true when admin permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [true, false, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitForSinglePreflightToFinish();
await waitFor(
() => {
expect(result.current.user.role).toBe(USER_ROLES.ADMIN);
expect(result.current.hasEditPermission).toBe(true);
},
{ timeout: 2000 },
);
});
it('sets user.role to EDITOR and hasEditPermission to true when only editor permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, true, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitForSinglePreflightToFinish();
await waitFor(
() => {
expect(result.current.user.role).toBe(USER_ROLES.EDITOR);
expect(result.current.hasEditPermission).toBe(true);
},
{ timeout: 2000 },
);
});
it('sets user.role to VIEWER and hasEditPermission to false when only viewer permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false, true])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitForSinglePreflightToFinish();
await waitFor(
() => {
expect(result.current.user.role).toBe(USER_ROLES.VIEWER);
expect(result.current.hasEditPermission).toBe(false);
},
{ timeout: 2000 },
);
});
it('sets user.role to ANONYMOUS and hasEditPermission to false when no role permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitForSinglePreflightToFinish();
await waitFor(
() => {
expect(result.current.user.role).toBe(USER_ROLES.ANONYMOUS);
expect(result.current.hasEditPermission).toBe(false);
},
{ timeout: 2000 },
);
});
/**
* This is expected to not happen, but we'll test it just in case.
*/
describe('when multiple role permissions are granted', () => {
it('prefers ADMIN over EDITOR and VIEWER when multiple role permissions are granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [true, true, true])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.user.role).toBe(USER_ROLES.ADMIN);
expect(result.current.hasEditPermission).toBe(true);
},
{ timeout: 300 },
);
});
it('prefers EDITOR over VIEWER when editor and viewer permissions are granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, true, true])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitForSinglePreflightToFinish();
await waitFor(
() => {
expect(result.current.user.role).toBe(USER_ROLES.EDITOR);
expect(result.current.hasEditPermission).toBe(true);
},
{ timeout: 2000 },
);
});
});
});
describe('AppProvider when authz/check fails', () => {
beforeEach(() => {
queryClient.clear();
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
});
it('sets userFetchError when authz/check returns 500 (same as user fetch error)', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitForSinglePreflightToFinish();
await waitFor(
() => {
expect(result.current.userFetchError).toBeTruthy();
},
{ timeout: 2000 },
);
});
it('sets userFetchError when authz/check fails with network error (same as user fetch error)', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_, res) => res.networkError('Network error')),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitForSinglePreflightToFinish();
await waitFor(
() => {
expect(result.current.userFetchError).toBeTruthy();
},
{ timeout: 2000 },
);
});
});

View File

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

View File

@@ -13,6 +13,9 @@ export interface UserResponse {
displayName: string;
orgId: string;
organization: string;
/**
* @deprecated This will be removed in the future releases in favor of new AuthZ framework
*/
role: ROLES;
updatedAt?: number;
}

View File

@@ -2,14 +2,16 @@ export type ADMIN = 'ADMIN';
export type VIEWER = 'VIEWER';
export type EDITOR = 'EDITOR';
export type AUTHOR = 'AUTHOR';
export type ANONYMOUS = 'ANONYMOUS';
export type ROLES = ADMIN | VIEWER | EDITOR | AUTHOR;
export type ROLES = ADMIN | VIEWER | EDITOR | AUTHOR | ANONYMOUS;
export const USER_ROLES = {
ADMIN: 'ADMIN',
VIEWER: 'VIEWER',
EDITOR: 'EDITOR',
AUTHOR: 'AUTHOR',
ANONYMOUS: 'ANONYMOUS',
};
export enum RoleType {

View File

@@ -69,7 +69,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ALERT_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'],
FORGOT_PASSWORD: ['ADMIN', 'EDITOR', 'VIEWER'],
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'],
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -77,7 +77,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER'],
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -101,7 +101,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ROLE_DETAILS: ['ADMIN'],
MEMBERS_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -4506,6 +4506,19 @@
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-escape-keydown" "1.1.1"
"@radix-ui/react-dropdown-menu@^2.1.16":
version "2.1.16"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz#5ee045c62bad8122347981c479d92b1ff24c7254"
integrity sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-menu" "2.1.16"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-focus-guards@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz#339c1c69c41628c1a5e655f15f7020bf11aa01fa"
@@ -4565,6 +4578,30 @@
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-menu@2.1.16":
version "2.1.16"
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz#528a5a973c3a7413d3d49eb9ccd229aa52402911"
integrity sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-collection" "1.1.7"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-direction" "1.1.1"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-popper" "1.2.8"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-roving-focus" "1.1.11"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-callback-ref" "1.1.1"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-popover@^1.1.15", "@radix-ui/react-popover@^1.1.2":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz#9c852f93990a687ebdc949b2c3de1f37cdc4c5d5"
@@ -4804,6 +4841,20 @@
"@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-tabs@^1.1.3":
version "1.1.13"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15"
integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-direction" "1.1.1"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-roving-focus" "1.1.11"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-toggle-group@^1.1.7":
version "1.1.11"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz#e513d6ffdb07509b400ab5b26f2523747c0d51c1"
@@ -5675,6 +5726,42 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.5.tgz#8badef53416b7ace0fe61ff01ff3da679a0e4ba5"
integrity sha512-4vPvUh3rwpst068qXUZ26JfCQGv1vo1xMSwtKw6wTjiiq1Bf3geP84HWVXycNMIrIeVnUgDGnqe0D4doh+mL8A==
dependencies:
"@radix-ui/react-checkbox" "^1.2.3"
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-dropdown-menu" "^2.1.16"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-popover" "^1.1.15"
"@radix-ui/react-radio-group" "^1.3.4"
"@radix-ui/react-slot" "^1.2.3"
"@radix-ui/react-switch" "^1.1.4"
"@radix-ui/react-tabs" "^1.1.3"
"@radix-ui/react-toggle" "^1.1.6"
"@radix-ui/react-toggle-group" "^1.1.7"
"@radix-ui/react-tooltip" "^1.2.6"
"@tanstack/react-table" "^8.21.3"
"@tanstack/react-virtual" "^3.13.9"
"@types/lodash-es" "^4.17.12"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
cmdk "^1.1.1"
date-fns "^4.1.0"
dayjs "^1.11.10"
lodash-es "^4.17.21"
lucide-react "^0.445.0"
lucide-solid "^0.510.0"
motion "^11.11.17"
next-themes "^0.4.6"
nuqs "^2.8.9"
react-day-picker "^9.8.1"
react-resizable-panels "^4.7.1"
sonner "^2.0.7"
tailwind-merge "^3.5.0"
"@sinclair/typebox@^0.25.16":
version "0.25.24"
resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz"
@@ -9573,6 +9660,11 @@ dayjs@^1.10.7, dayjs@^1.11.1:
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz"
integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==
dayjs@^1.11.10:
version "1.11.20"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938"
integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==
debounce@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
@@ -11092,6 +11184,15 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
framer-motion@^11.18.2:
version "11.18.2"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-11.18.2.tgz#0c6bd05677f4cfd3b3bdead4eb5ecdd5ed245718"
integrity sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==
dependencies:
motion-dom "^11.18.1"
motion-utils "^11.18.1"
tslib "^2.4.0"
framer-motion@^12.4.13:
version "12.4.13"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.4.13.tgz#1efd954f95e6a54685b660929c00f5a61e35256a"
@@ -15002,6 +15103,13 @@ moment@^2.29.4:
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
motion-dom@^11.18.1:
version "11.18.1"
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-11.18.1.tgz#e7fed7b7dc6ae1223ef1cce29ee54bec826dc3f2"
integrity sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==
dependencies:
motion-utils "^11.18.1"
motion-dom@^12.4.11:
version "12.4.11"
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.4.11.tgz#0419c8686cda4d523f08249deeb8fa6683a9b9d3"
@@ -15009,6 +15117,11 @@ motion-dom@^12.4.11:
dependencies:
motion-utils "^12.4.10"
motion-utils@^11.18.1:
version "11.18.1"
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-11.18.1.tgz#671227669833e991c55813cf337899f41327db5b"
integrity sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==
motion-utils@^12.4.10:
version "12.4.10"
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.4.10.tgz#3d93acea5454419eaaad8d5e5425cb71cbfa1e7f"
@@ -15022,6 +15135,14 @@ motion@12.4.13:
framer-motion "^12.4.13"
tslib "^2.4.0"
motion@^11.11.17:
version "11.18.2"
resolved "https://registry.yarnpkg.com/motion/-/motion-11.18.2.tgz#17fb372f3ed94fc9ee1384a25a9068e9da1951e7"
integrity sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg==
dependencies:
framer-motion "^11.18.2"
tslib "^2.4.0"
mri@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
@@ -15292,6 +15413,13 @@ nuqs@2.8.8:
dependencies:
"@standard-schema/spec" "1.0.0"
nuqs@^2.8.9:
version "2.8.9"
resolved "https://registry.yarnpkg.com/nuqs/-/nuqs-2.8.9.tgz#e2c27d87c0dd0e3b4412fe867bcd0947cc4c998f"
integrity sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==
dependencies:
"@standard-schema/spec" "1.0.0"
nwsapi@^2.2.2:
version "2.2.23"
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.23.tgz#59712c3a88e6de2bb0b6ccc1070397267019cf6c"
@@ -16957,6 +17085,11 @@ react-resizable-panels@^3.0.5:
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-3.0.5.tgz#50a20645263eed02344de4a70d1319bbc0014bbd"
integrity sha512-3z1yN25DMTXLg2wfyFrW32r5k4WEcUa3F7cJ2EgtNK07lnOs4mpM8yWLGunCpkhcQRwJX4fqoLcIh/pHPxzlmQ==
react-resizable-panels@^4.7.1:
version "4.7.3"
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-4.7.3.tgz#4040aa0f5c5c4cc4bb685cb69973601ccda3b014"
integrity sha512-PYcYMLtvJD+Pr0TQNeMvddcnLOwUa/Yb4iNwU7ThNLlHaQYEEC9MIBWHaBGODzYuXIkPRZ/OWe5sbzG1Rzq5ew==
react-resizable@3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.4.tgz"
@@ -18797,6 +18930,11 @@ tailwind-merge@^2.5.2:
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
tailwind-merge@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca"
integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
tailwindcss-animate@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"

2
go.mod
View File

@@ -11,7 +11,6 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.2
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -106,6 +105,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -41,9 +40,7 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == 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)
@@ -93,9 +90,7 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == 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)
@@ -144,9 +139,7 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
// add the paths that are not promoted but have indexes
for path, indexes := range aggr {
path := strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix)
path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
path = telemetrytypes.BodyJSONStringSearchPrefix + path
response = append(response, promotetypes.PromotePath{
Path: path,
@@ -163,7 +163,7 @@ func (m *module) PromoteAndIndexPaths(
}
}
if len(it.Indexes) > 0 {
parentColumn := telemetrylogs.LogsV2BodyJSONColumn
parentColumn := telemetrylogs.LogsV2BodyV2Column
// if the path is already promoted or is being promoted, add it to the promoted column
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,13 +10,11 @@ import (
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
type builderQuery[T any] struct {
@@ -262,40 +260,6 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
return nil, err
}
// merge body_json and promoted into body
if q.spec.Signal == telemetrytypes.SignalLogs {
switch typedPayload := payload.(type) {
case *qbtypes.RawData:
for _, rr := range typedPayload.Rows {
seeder := func() error {
body, ok := rr.Data[telemetrylogs.LogsV2BodyJSONColumn].(map[string]any)
if !ok {
return nil
}
promoted, ok := rr.Data[telemetrylogs.LogsV2BodyPromotedColumn].(map[string]any)
if !ok {
return nil
}
seed(promoted, body)
str, err := sonic.MarshalString(body)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal body")
}
rr.Data["body"] = str
return nil
}
err := seeder()
if err != nil {
return nil, err
}
delete(rr.Data, telemetrylogs.LogsV2BodyJSONColumn)
delete(rr.Data, telemetrylogs.LogsV2BodyPromotedColumn)
}
payload = typedPayload
}
}
return &qbtypes.Result{
Type: q.kind,
Value: payload,
@@ -423,18 +387,3 @@ func decodeCursor(cur string) (int64, error) {
}
return strconv.ParseInt(string(b), 10, 64)
}
func seed(promoted map[string]any, body map[string]any) {
for key, fromValue := range promoted {
if toValue, ok := body[key]; !ok {
body[key] = fromValue
} else {
if fromValue, ok := fromValue.(map[string]any); ok {
if toValue, ok := toValue.(map[string]any); ok {
seed(fromValue, toValue)
body[key] = toValue
}
}
}
}
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
var (
@@ -394,17 +393,11 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: normalize into structured values
// Post-process JSON columns: normalize into String value
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) {
case []byte:
if len(x) > 0 {
var v any
if err := sonic.Unmarshal(x, &v); err == nil {
val = v
}
}
val = string(x)
default:
// already a structured type (map[string]any, []any, etc.)
}

View File

@@ -177,7 +177,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
onConflictClause := ""
if len(onConflictSetStmts) > 0 {
onConflictClause = fmt.Sprintf(
"conflict(id, provider, org_id) do update SET\n%s",
"conflict(id) do update SET\n%s",
strings.Join(onConflictSetStmts, ",\n"),
)
}
@@ -202,6 +202,8 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
Exec(ctx)
if dbErr != nil {
// for now returning internal error even if there is a conflict,
// will be handled better in the future iteration
return nil, model.InternalError(fmt.Errorf(
"could not upsert cloud account record: %w", dbErr,
))

View File

@@ -131,32 +131,38 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
Alias: "NormalizeBodyDefault",
Enabled: true,
},
Filter: &v3.FilterSet{
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "body",
func (ic *LogParsingPipelineController) getDefaultPipelines() ([]pipelinetypes.GettablePipeline, error) {
defaultPipelines := []pipelinetypes.GettablePipeline{}
if querybuilder.BodyJSONQueryEnabled {
preprocessingPipeline := pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
Alias: "NormalizeBodyDefault",
Enabled: true,
},
Filter: &v3.FilterSet{
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "body",
},
Operator: v3.FilterOperatorExists,
},
Operator: v3.FilterOperatorExists,
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
},
},
},
}
defaultPipelines = append(defaultPipelines, preprocessingPipeline)
}
return defaultPipelines, nil
}
// Returns effective list of pipelines including user created
@@ -289,10 +295,12 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
if querybuilder.BodyJSONQueryEnabled {
// add default normalize pipeline at the beginning
pipelinesResp.Pipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, pipelinesResp.Pipelines...)
// recommend default pipelines along with user created pipelines
defaultPipelines, err := pc.getDefaultPipelines()
if err != nil {
return nil, "", model.InternalError(fmt.Errorf("failed to get default pipelines: %w", err))
}
pipelinesResp.Pipelines = append(pipelinesResp.Pipelines, defaultPipelines...)
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, pipelinesResp.Pipelines)
if err != nil {

View File

@@ -7,12 +7,14 @@ import (
"sync"
"time"
"log/slog"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
opentracing "github.com/opentracing/opentracing-go"
plabels "github.com/prometheus/prometheus/model/labels"
"log/slog"
)
// PromRuleTask is a promql rule executor
@@ -371,7 +373,7 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("rule_id", rule.ID())
comment.Set("auth_type", "internal")
comment.Set("identn_provider", authtypes.IdentNProviderInternal.StringValue())
ctx = ctxtypes.NewContextWithComment(ctx, comment)
_, err := rule.Eval(ctx, ts)

View File

@@ -10,6 +10,7 @@ import (
"log/slog"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -358,7 +359,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("rule_id", rule.ID())
comment.Set("auth_type", "internal")
comment.Set("identn_provider", authtypes.IdentNProviderInternal.StringValue())
ctx = ctxtypes.NewContextWithComment(ctx, comment)
_, err := rule.Eval(ctx, ts)

Some files were not shown because too many files have changed in this diff Show More